Merge pull request #115 from emeraldpay/feat/native-subscribe

This commit is contained in:
Igor Artamonov
2021-10-20 12:22:52 -04:00
committed by GitHub
48 changed files with 2735 additions and 65 deletions

View File

@@ -61,7 +61,7 @@ configurations {
} }
dependencies { dependencies {
implementation "io.emeraldpay:emerald-api:0.9.4" implementation "io.emeraldpay:emerald-api:0.10.0"
implementation "io.grpc:grpc-protobuf:${grpcVersion}" implementation "io.grpc:grpc-protobuf:${grpcVersion}"
implementation "io.grpc:grpc-stub:${grpcVersion}" implementation "io.grpc:grpc-stub:${grpcVersion}"
@@ -114,6 +114,7 @@ dependencies {
implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonVersion" implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonVersion"
implementation "com.fasterxml.jackson.datatype:jackson-datatype-jdk8:$jacksonVersion" implementation "com.fasterxml.jackson.datatype:jackson-datatype-jdk8:$jacksonVersion"
implementation "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:$jacksonVersion" implementation "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:$jacksonVersion"
implementation "com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion"
implementation 'commons-io:commons-io:2.6' implementation 'commons-io:commons-io:2.6'
implementation 'org.apache.commons:commons-lang3:3.9' implementation 'org.apache.commons:commons-lang3:3.9'
implementation 'org.apache.commons:commons-collections4:4.3' implementation 'org.apache.commons:commons-collections4:4.3'

View File

@@ -23,6 +23,7 @@ service Blockchain {
rpc GetBalance (BalanceRequest) returns (stream AddressBalance) {} rpc GetBalance (BalanceRequest) returns (stream AddressBalance) {}
rpc NativeCall (NativeCallRequest) returns (stream NativeCallReplyItem) {} rpc NativeCall (NativeCallRequest) returns (stream NativeCallReplyItem) {}
rpc NativeSubscribe (NativeSubscribeRequest) returns (stream NativeSubscribeReplyItem) {}
rpc Describe (DescribeRequest) returns (DescribeResponse) {} rpc Describe (DescribeRequest) returns (DescribeResponse) {}
rpc SubscribeStatus (StatusRequest) returns (stream ChainStatus) {} rpc SubscribeStatus (StatusRequest) returns (stream ChainStatus) {}
@@ -80,6 +81,50 @@ Where:
NOTE: Reply Items comes right after their execution on an upstream, therefore streaming response. NOTE: Reply Items comes right after their execution on an upstream, therefore streaming response.
It allows to build non-blocking queries It allows to build non-blocking queries
=== Wrapped JSON RPC subscriptions
Most of Ethereum APIs provides _subscription_ to events usually accessed through WebSocket connection.
Dshackle gives access to same events through gRPC protocol via the `NativeSubscribe` method.
NOTE: Dshackle doesn't actually wrap existing subscription or dispatch request to an upstream.
It rather generates same events based on the available data, i.e., aggregates it from multiple upstreams.
Supported subscriptions:
- `newHeads`
- `logs`
- `syncing`
Method data:
[source,proto]
----
message NativeSubscribeRequest {
ChainRef chain = 1;
string method = 2;
bytes payload = 3;
}
message NativeSubscribeReplyItem {
bytes payload = 1;
}
----
Where:
- `method` is a subscriptions method (one of `newHeads`, `logs` or `syncing`)
- `payload` in request is optional subscription params object, which exists only for `logs` methods.
In that case it may be `address` or `topics`.
Both address and topics can be a string or array of strings.
Empty payload for `logs` accepted as subscription to _all_ events.
- `payload` in reply item is as subscription response encoded as JSON
For example to subscribe to USDC ERC-20 coin Approval events on Ethereum mainnet the request would be:
- `chain=100`
- `method=logs`
- `payload={"address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "topics": ["0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925"]}`
=== SubscribeHead === SubscribeHead
This methods provides subscription to the new blocks on the specified chain. This methods provides subscription to the new blocks on the specified chain.

View File

@@ -4,23 +4,17 @@ option java_package = "io.emeraldpay.api.proto";
import "common.proto"; import "common.proto";
service Blockchain { service Blockchain {
rpc SubscribeHead (Chain) returns (stream ChainHead) { rpc SubscribeHead (Chain) returns (stream ChainHead) {}
} rpc SubscribeBalance (BalanceRequest) returns (stream AddressBalance) {}
rpc SubscribeBalance (BalanceRequest) returns (stream AddressBalance) { rpc SubscribeTxStatus (TxStatusRequest) returns (stream TxStatus) {}
}
rpc SubscribeTxStatus (TxStatusRequest) returns (stream TxStatus) {
}
rpc GetBalance (BalanceRequest) returns (stream AddressBalance) { rpc GetBalance (BalanceRequest) returns (stream AddressBalance) {}
}
rpc NativeCall (NativeCallRequest) returns (stream NativeCallReplyItem) { rpc NativeCall (NativeCallRequest) returns (stream NativeCallReplyItem) {}
} rpc NativeSubscribe (NativeSubscribeRequest) returns (stream NativeSubscribeReplyItem) {}
rpc Describe (DescribeRequest) returns (DescribeResponse) { rpc Describe (DescribeRequest) returns (DescribeResponse) {}
} rpc SubscribeStatus (StatusRequest) returns (stream ChainStatus) {}
rpc SubscribeStatus (StatusRequest) returns (stream ChainStatus) {
}
} }
message NativeCallRequest { message NativeCallRequest {
@@ -44,6 +38,16 @@ message NativeCallReplyItem {
string errorMessage = 4; string errorMessage = 4;
} }
message NativeSubscribeRequest {
ChainRef chain = 1;
string method = 2;
bytes payload = 3;
}
message NativeSubscribeReplyItem {
bytes payload = 1;
}
message ChainHead { message ChainHead {
ChainRef chain = 1; ChainRef chain = 1;
uint64 height = 2; uint64 height = 2;
@@ -70,12 +74,22 @@ message TxStatus {
message BalanceRequest { message BalanceRequest {
Asset asset = 1; Asset asset = 1;
AnyAddress address = 2; AnyAddress address = 2;
bool include_utxo = 3;
} }
message AddressBalance { message AddressBalance {
Asset asset = 1; Asset asset = 1;
SingleAddress address = 2; SingleAddress address = 2;
string balance = 3; string balance = 3;
bool confirmed = 4;
repeated Utxo utxo = 5;
}
message Utxo {
string tx_id = 1;
uint64 index = 2;
string balance = 3;
bool spent = 4;
} }
message DescribeRequest { message DescribeRequest {
@@ -91,6 +105,7 @@ message DescribeChain {
repeated NodeDetails nodes = 3; repeated NodeDetails nodes = 3;
repeated string supportedMethods = 4; repeated string supportedMethods = 4;
repeated string excludedMethods = 5; repeated string excludedMethods = 5;
repeated Capabilities capabilities = 6;
} }
message StatusRequest { message StatusRequest {
@@ -117,6 +132,12 @@ message NodeDetails {
repeated Label labels = 2; repeated Label labels = 2;
} }
enum Capabilities {
CAP_NONE = 0;
CAP_CALLS = 1;
CAP_BALANCE = 2;
}
message Label { message Label {
string name = 1; string name = 1;
string value = 2; string value = 2;

View File

@@ -33,6 +33,7 @@ open class Caches(
private val memBlocksByHash: BlocksMemCache, private val memBlocksByHash: BlocksMemCache,
private val blocksByHeight: HeightCache, private val blocksByHeight: HeightCache,
private val memTxsByHash: TxMemCache, private val memTxsByHash: TxMemCache,
private val memReceipts: ReceiptMemCache,
private val redisBlocksByHash: BlocksRedisCache?, private val redisBlocksByHash: BlocksRedisCache?,
private val redisTxsByHash: TxRedisCache?, private val redisTxsByHash: TxRedisCache?,
private val redisReceipts: ReceiptRedisCache?, private val redisReceipts: ReceiptRedisCache?,
@@ -59,6 +60,8 @@ open class Caches(
private val txsByHash: Reader<TxId, TxContainer> private val txsByHash: Reader<TxId, TxContainer>
private val receiptByHash: Reader<TxId, ByteArray> private val receiptByHash: Reader<TxId, ByteArray>
private var head: Head? = null
init { init {
blocksByHash = if (redisBlocksByHash == null) { blocksByHash = if (redisBlocksByHash == null) {
memBlocksByHash memBlocksByHash
@@ -70,26 +73,24 @@ open class Caches(
} else { } else {
CompoundReader(memTxsByHash, redisTxsByHash) CompoundReader(memTxsByHash, redisTxsByHash)
} }
receiptByHash = redisReceipts ?: EmptyReader() receiptByHash = if (redisReceipts == null) {
memReceipts
} else {
CompoundReader(memReceipts, redisReceipts)
}
} }
fun setHead(head: Head) { fun setHead(head: Head) {
this.head = head
redisTxsByHash?.head = head redisTxsByHash?.head = head
redisReceipts?.head = head redisReceipts?.head = head
} }
/**
* Cache data that was just requested
*/
fun cacheRequested(data: Any) {
if (data is TxContainer) {
cache(Tag.REQUESTED, data)
} else if (data is BlockContainer) {
cache(Tag.REQUESTED, data)
}
}
open fun cacheReceipt(tag: Tag, data: DefaultContainer<TransactionReceiptJson>) { open fun cacheReceipt(tag: Tag, data: DefaultContainer<TransactionReceiptJson>) {
val currentHeight = head?.getCurrentHeight()
if (currentHeight != null && data.height != null && memReceipts.acceptsRecentBlocks(currentHeight - data.height)) {
memReceipts.add(data)
}
//TODO move subscription to the caller //TODO move subscription to the caller
redisReceipts?.add(data)?.subscribe() redisReceipts?.add(data)?.subscribe()
} }
@@ -165,6 +166,7 @@ open class Caches(
memBlocksByHash.get(blockId)?.let { block -> memBlocksByHash.get(blockId)?.let { block ->
memTxsByHash.evict(block) memTxsByHash.evict(block)
redisTxsByHash?.evict(block) redisTxsByHash?.evict(block)
memReceipts.evict(block)
evicted = true evicted = true
} }
if (!evicted) { if (!evicted) {
@@ -224,6 +226,7 @@ open class Caches(
private var blocksByHash: BlocksMemCache? = null private var blocksByHash: BlocksMemCache? = null
private var blocksByHeight: HeightCache? = null private var blocksByHeight: HeightCache? = null
private var txsByHash: TxMemCache? = null private var txsByHash: TxMemCache? = null
private var receipts: ReceiptMemCache? = null
private var redisBlocksByHash: BlocksRedisCache? = null private var redisBlocksByHash: BlocksRedisCache? = null
private var redisTxsByHash: TxRedisCache? = null private var redisTxsByHash: TxRedisCache? = null
private var redisReceiptCache: ReceiptRedisCache? = null private var redisReceiptCache: ReceiptRedisCache? = null
@@ -259,6 +262,11 @@ open class Caches(
return this return this
} }
fun setReceipts(cache: ReceiptMemCache): Builder {
this.receipts = cache
return this
}
fun setHeightByHash(cache: HeightByHashRedisCache): Builder { fun setHeightByHash(cache: HeightByHashRedisCache): Builder {
redisHeightByHashCache = cache redisHeightByHashCache = cache
return this return this
@@ -274,7 +282,10 @@ open class Caches(
if (txsByHash == null) { if (txsByHash == null) {
txsByHash = TxMemCache() txsByHash = TxMemCache()
} }
return Caches(blocksByHash!!, blocksByHeight!!, txsByHash!!, if (receipts == null) {
receipts = ReceiptMemCache()
}
return Caches(blocksByHash!!, blocksByHeight!!, txsByHash!!, receipts!!,
redisBlocksByHash, redisTxsByHash, redisReceiptCache, redisHeightByHashCache) redisBlocksByHash, redisTxsByHash, redisReceiptCache, redisHeightByHashCache)
} }
} }

View File

@@ -0,0 +1,64 @@
/**
* 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.cache
import com.github.benmanes.caffeine.cache.Caffeine
import io.emeraldpay.dshackle.data.BlockContainer
import io.emeraldpay.dshackle.data.DefaultContainer
import io.emeraldpay.dshackle.data.TxId
import io.emeraldpay.dshackle.reader.Reader
import io.emeraldpay.etherjar.rpc.json.TransactionReceiptJson
import org.slf4j.LoggerFactory
import reactor.core.publisher.Mono
/**
* Keeps receipts for recent blocks in memory
*/
open class ReceiptMemCache(
// how many blocks to keeps in memory
val blocks: Int = 6
) : Reader<TxId, ByteArray> {
companion object {
private val log = LoggerFactory.getLogger(ReceiptMemCache::class.java)
}
private val mapping = Caffeine.newBuilder()
.maximumSize(blocks * 200L)
.build<TxId, ByteArray>()
open fun evict(block: BlockContainer) {
block.transactions.forEach {
mapping.invalidate(it)
}
}
override fun read(key: TxId): Mono<ByteArray> {
return mapping.getIfPresent(key)?.let { Mono.just(it) } ?: Mono.empty()
}
open fun add(receipt: DefaultContainer<TransactionReceiptJson>): Mono<Void> {
if (receipt.txId != null && receipt.json != null) {
mapping.put(receipt.txId, receipt.json)
}
return Mono.empty()
}
open fun acceptsRecentBlocks(heightDelta: Long): Boolean {
return blocks <= heightDelta && heightDelta >= 0
}
}

View File

@@ -23,7 +23,7 @@ import io.emeraldpay.etherjar.rpc.json.TransactionReceiptJson
import io.lettuce.core.api.reactive.RedisReactiveCommands import io.lettuce.core.api.reactive.RedisReactiveCommands
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
class ReceiptRedisCache( open class ReceiptRedisCache(
redis: RedisReactiveCommands<String, ByteArray>, redis: RedisReactiveCommands<String, ByteArray>,
chain: Chain chain: Chain
) : OnTxRedisCache<ByteArray>(redis, chain, CachesProto.ValueContainer.ValueType.TX_RECEIPT) { ) : OnTxRedisCache<ByteArray>(redis, chain, CachesProto.ValueContainer.ValueType.TX_RECEIPT) {

View File

@@ -40,6 +40,7 @@ class AccessHandlerGrpc(
"SubscribeTxStatus" -> processSubscribeTxStatus(call, headers, next) "SubscribeTxStatus" -> processSubscribeTxStatus(call, headers, next)
"GetBalance" -> processSubscribeBalance(call, headers, next, false) "GetBalance" -> processSubscribeBalance(call, headers, next, false)
"NativeCall" -> processNativeCall(call, headers, next) "NativeCall" -> processNativeCall(call, headers, next)
"NativeSubscribe" -> processNativeSubscribe(call, headers, next)
"Describe" -> processDescribe(call, headers, next) "Describe" -> processDescribe(call, headers, next)
"SubscribeStatus" -> processStatus(call, headers, next) "SubscribeStatus" -> processStatus(call, headers, next)
else -> { else -> {
@@ -110,6 +111,18 @@ class AccessHandlerGrpc(
) )
} }
@Suppress("UNCHECKED_CAST")
private fun <ReqT : Any, RespT : Any> processNativeSubscribe(
call: ServerCall<ReqT, RespT>,
headers: Metadata,
next: ServerCallHandler<ReqT, RespT>
): ServerCall.Listener<ReqT> {
return process(call, headers, next,
EventsBuilder.NativeSubscribe() as EventsBuilder.RequestReply<*, ReqT, RespT>
)
}
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
private fun <ReqT : Any, RespT : Any> processDescribe( private fun <ReqT : Any, RespT : Any> processDescribe(
call: ServerCall<ReqT, RespT>, call: ServerCall<ReqT, RespT>,

View File

@@ -102,6 +102,16 @@ class Events {
val nativeCall: NativeCallItemDetails val nativeCall: NativeCallItemDetails
) : ChainBase(blockchain, "NativeCall", id, channel) ) : ChainBase(blockchain, "NativeCall", id, channel)
@JsonInclude(JsonInclude.Include.NON_NULL)
class NativeSubscribe(
blockchain: Chain, id: UUID, channel: Channel,
// info about the initial request, that may include several native calls
val request: StreamRequestDetails,
val payloadSizeBytes: Long,
val nativeSubscribe: NativeSubscribeItemDetails
) : ChainBase(blockchain, "NativeSubscribe", id, channel)
@JsonInclude(JsonInclude.Include.NON_NULL) @JsonInclude(JsonInclude.Include.NON_NULL)
class Describe( class Describe(
id: UUID, id: UUID,
@@ -139,6 +149,16 @@ class Events {
val ts: Instant = Instant.now() val ts: Instant = Instant.now()
) )
data class NativeSubscribeItemDetails(
val method: String,
val payloadSizeBytes: Long
)
data class NativeSubscribeReplyDetails(
val replySizeBytes: Long,
val ts: Instant = Instant.now()
)
data class BalanceRequest( data class BalanceRequest(
val asset: String, val asset: String,
val addressType: String val addressType: String

View File

@@ -293,6 +293,36 @@ class EventsBuilder {
} }
} }
class NativeSubscribe :
Base<NativeSubscribe>(),
RequestReply<Events.NativeSubscribe, BlockchainOuterClass.NativeSubscribeRequest, BlockchainOuterClass.NativeSubscribeReplyItem> {
var item: Events.NativeSubscribeItemDetails? = null
val replies = HashMap<Int, Events.NativeSubscribeReplyDetails>()
override fun getT(): NativeSubscribe {
return this
}
override fun onRequest(msg: BlockchainOuterClass.NativeSubscribeRequest) {
withChain(msg.chain.number)
this.item = Events.NativeSubscribeItemDetails(
msg.method,
msg.payload.size().toLong()
)
}
override fun onReply(msg: BlockchainOuterClass.NativeSubscribeReplyItem): Events.NativeSubscribe {
return Events.NativeSubscribe(
request = requestDetails,
blockchain = chain,
nativeSubscribe = item!!,
payloadSizeBytes = msg.payload?.size()?.toLong() ?: 0L,
id = UUID.randomUUID(),
channel = Events.Channel.GRPC
)
}
}
class Describe : class Describe :
Base<Describe>(), Base<Describe>(),
RequestReply<Events.Describe, BlockchainOuterClass.DescribeRequest, BlockchainOuterClass.DescribeResponse> { RequestReply<Events.Describe, BlockchainOuterClass.DescribeRequest, BlockchainOuterClass.DescribeResponse> {

View File

@@ -23,7 +23,8 @@ import reactor.core.publisher.Mono
import java.time.Duration import java.time.Duration
/** /**
* Composition of multiple readers. Reader returns first value returned by any of the source readers. * Composition of multiple readers.
* Reader returns first value returned by any of the source readers by checking one by one until one of them returns a non-empty result.
*/ */
class CompoundReader<K, D>( class CompoundReader<K, D>(
private vararg val readers: Reader<K, D> private vararg val readers: Reader<K, D>
@@ -38,12 +39,13 @@ class CompoundReader<K, D>(
return Mono.empty() return Mono.empty()
} }
return Flux.fromIterable(readers.asIterable()) return Flux.fromIterable(readers.asIterable())
.flatMap { rdr -> .flatMap({ rdr ->
rdr.read(key) rdr.read(key)
.timeout(Defaults.timeoutInternal, Mono.empty()) .timeout(Defaults.timeoutInternal, Mono.empty())
.doOnError { t -> log.warn("Failed to read from $rdr", t) } .doOnError { t -> log.warn("Failed to read from $rdr", t) }
.onErrorResume { Mono.empty() } .onErrorResume { Mono.empty() }
}.next() }, 1)
.next()
} }
} }

View File

@@ -0,0 +1,55 @@
/**
* 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.reader
import io.emeraldpay.dshackle.upstream.Multistream
import io.emeraldpay.dshackle.upstream.Selector
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcRequest
import org.slf4j.LoggerFactory
import reactor.core.publisher.Mono
/**
* Reader that requests data through upstream RPC using provided JSON RPC request builder
*/
class RpcReader<T>(
private val up: Multistream,
private val paramsBuilder: (T) -> JsonRpcRequest
) : Reader<T, ByteArray> {
companion object {
private val log = LoggerFactory.getLogger(RpcReader::class.java)
/**
* Common reader that just passes key as a parameter with the specified method. The key must be serializable to JSON.
* @param method RPC method to use
*/
fun <T> basicRequest(up: Multistream, method: String): RpcReader<T> {
return RpcReader(up) { key ->
JsonRpcRequest(method, listOf(key))
}
}
}
override fun read(key: T): Mono<ByteArray> {
return up.getDirectApi(Selector.empty)
.flatMap { rdr ->
rdr.read(paramsBuilder(key)).flatMap {
it.requireResult()
}
}
}
}

View File

@@ -37,6 +37,7 @@ import java.util.concurrent.TimeUnit
@Service @DependsOn("monitoringSetup") @Service @DependsOn("monitoringSetup")
class BlockchainRpc( class BlockchainRpc(
@Autowired private val nativeCall: NativeCall, @Autowired private val nativeCall: NativeCall,
@Autowired private val nativeSubscribe: NativeSubscribe,
@Autowired private val streamHead: StreamHead, @Autowired private val streamHead: StreamHead,
@Autowired private val trackTx: List<TrackTx>, @Autowired private val trackTx: List<TrackTx>,
@Autowired private val trackAddress: List<TrackAddress>, @Autowired private val trackAddress: List<TrackAddress>,
@@ -73,6 +74,19 @@ class BlockchainRpc(
}.doOnError { errorMetric.increment() } }.doOnError { errorMetric.increment() }
} }
override fun nativeSubscribe(request: Mono<BlockchainOuterClass.NativeSubscribeRequest>): Flux<BlockchainOuterClass.NativeSubscribeReplyItem> {
var metrics: RequestMetrics? = null
return nativeSubscribe.nativeSubscribe(
request
.doOnNext {
metrics = chainMetrics.get(it.chain)
metrics!!.nativeSubscribeMetric.increment()
}
).doOnNext {
metrics?.nativeSubscribeRespMetric?.increment()
}.doOnError { errorMetric.increment() }
}
override fun subscribeHead(request: Mono<Common.Chain>): Flux<BlockchainOuterClass.ChainHead> { override fun subscribeHead(request: Mono<Common.Chain>): Flux<BlockchainOuterClass.ChainHead> {
return streamHead.add( return streamHead.add(
request request
@@ -169,6 +183,14 @@ class BlockchainRpc(
.tag("chain", chain.chainCode) .tag("chain", chain.chainCode)
.publishPercentileHistogram() .publishPercentileHistogram()
.register(Metrics.globalRegistry) .register(Metrics.globalRegistry)
val nativeSubscribeMetric = Counter.builder("request.grpc.request")
.tag("type", "nativeSubscribe")
.tag("chain", chain.chainCode)
.register(Metrics.globalRegistry)
val nativeSubscribeRespMetric = Counter.builder("request.grpc.response")
.tag("type", "nativeSubscribe")
.tag("chain", chain.chainCode)
.register(Metrics.globalRegistry)
val subscribeHeadMetric = Counter.builder("request.grpc.request") val subscribeHeadMetric = Counter.builder("request.grpc.request")
.tag("type", "subscribeHead") .tag("type", "subscribeHead")
.tag("chain", chain.chainCode) .tag("chain", chain.chainCode)

View File

@@ -200,12 +200,6 @@ open class NativeCall(
.map { .map {
CallResult(ctx.id, it.value, null) CallResult(ctx.id, it.value, null)
} }
.doOnNext {
it.result?.let { value ->
ctx.upstream.postprocessor
.onReceive(ctx.payload.method, ctx.payload.params, value)
}
}
.onErrorResume { t -> .onErrorResume { t ->
val failure = if (t is CallFailure) { val failure = if (t is CallFailure) {
CallResult.fail(t.id, t.reason) CallResult.fail(t.id, t.reason)

View File

@@ -0,0 +1,98 @@
/**
* 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.rpc
import com.google.protobuf.ByteString
import io.emeraldpay.api.proto.BlockchainOuterClass
import io.emeraldpay.dshackle.Global
import io.emeraldpay.dshackle.SilentException
import io.emeraldpay.dshackle.upstream.MultistreamHolder
import io.emeraldpay.dshackle.upstream.ethereum.EthereumMultistream
import io.emeraldpay.grpc.BlockchainType
import io.emeraldpay.grpc.Chain
import io.grpc.Status
import io.grpc.StatusException
import org.reactivestreams.Publisher
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
@Service
class NativeSubscribe(
@Autowired private val multistreamHolder: MultistreamHolder
) {
companion object {
private val log = LoggerFactory.getLogger(NativeSubscribe::class.java)
}
private val objectMapper = Global.objectMapper
fun nativeSubscribe(request: Mono<BlockchainOuterClass.NativeSubscribeRequest>): Flux<BlockchainOuterClass.NativeSubscribeReplyItem> {
return request
.flatMapMany(this@NativeSubscribe::start)
.map(this@NativeSubscribe::convertToProto)
.onErrorMap(this@NativeSubscribe::convertToStatus)
}
fun start(it: BlockchainOuterClass.NativeSubscribeRequest): Publisher<out Any> {
val chain = Chain.byId(it.chainValue)
if (BlockchainType.from(chain) != BlockchainType.ETHEREUM) {
return Mono.error(UnsupportedOperationException("Native subscribe is not supported for ${chain.chainCode}"))
}
val method = it.method
val params: Any? = it.payload?.let { payload ->
if (payload.size() > 0) {
objectMapper.readValue(payload.newInput(), Map::class.java)
} else {
null
}
}
return subscribe(chain, method, params)
}
fun convertToStatus(t: Throwable) = when (t) {
is SilentException.UnsupportedBlockchain -> StatusException(
Status.UNAVAILABLE.withDescription("BLOCKCHAIN UNAVAILABLE: ${t.blockchainId}")
)
is UnsupportedOperationException -> StatusException(
Status.UNIMPLEMENTED.withDescription(t.message)
)
else -> {
log.warn("Unhandled error", t)
StatusException(
Status.INTERNAL.withDescription(t.message)
)
}
}
fun subscribe(chain: Chain, method: String, params: Any?): Flux<out Any> {
val up = multistreamHolder.getUpstream(chain) ?: return Flux.error(SilentException.UnsupportedBlockchain(chain))
return (up as EthereumMultistream)
.getSubscribe()
.subscribe(method, params)
}
fun convertToProto(value: Any): BlockchainOuterClass.NativeSubscribeReplyItem {
val result = objectMapper.writeValueAsBytes(value)
return BlockchainOuterClass.NativeSubscribeReplyItem.newBuilder()
.setPayload(ByteString.copyFrom(result))
.build()
}
}

View File

@@ -140,6 +140,7 @@ abstract class Multistream(
apis.request(1) apis.request(1)
return Mono.from(apis) return Mono.from(apis)
.map(Upstream::getApi) .map(Upstream::getApi)
.map { RequestPostprocessor.wrap(it, postprocessor) } //TODO do it on upstream init, not each time it's called
.switchIfEmpty(Mono.error(Exception("No API available for $chain"))) .switchIfEmpty(Mono.error(Exception("No API available for $chain")))
} }

View File

@@ -1,10 +1,38 @@
package io.emeraldpay.dshackle.upstream package io.emeraldpay.dshackle.upstream
import io.emeraldpay.dshackle.reader.Reader
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcRequest
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcResponse
import reactor.core.publisher.Mono
interface RequestPostprocessor { interface RequestPostprocessor {
fun onReceive(method: String, params: List<Any>, json: ByteArray) fun onReceive(method: String, params: List<Any?>, json: ByteArray)
class Empty : RequestPostprocessor { class Empty : RequestPostprocessor {
override fun onReceive(method: String, params: List<Any>, json: ByteArray) {} override fun onReceive(method: String, params: List<Any?>, json: ByteArray) {}
}
companion object {
fun wrap(reader: Reader<JsonRpcRequest, JsonRpcResponse>, processor: RequestPostprocessor): Reader<JsonRpcRequest, JsonRpcResponse> {
return Wrapper(reader, processor)
}
}
class Wrapper(
private val reader: Reader<JsonRpcRequest, JsonRpcResponse>,
private val processor: RequestPostprocessor
) : Reader<JsonRpcRequest, JsonRpcResponse> {
override fun read(key: JsonRpcRequest): Mono<JsonRpcResponse> {
return reader.read(key)
.doOnNext {
if (it.hasResult()) {
val result = it.getResult()
processor.onReceive(key.method, key.params, result)
}
}
}
} }
} }

View File

@@ -26,6 +26,7 @@ class Selector {
companion object { companion object {
@JvmStatic
val empty = EmptyMatcher() val empty = EmptyMatcher()
@JvmStatic @JvmStatic

View File

@@ -23,7 +23,7 @@ enum class UpstreamAvailability(val grpcId: Int) {
*/ */
OK(1), OK(1),
/** /**
* Good node, but is still synchronizing a latest block * Good node, but is still synchronizing to a latest block
*/ */
LAGGING(2), LAGGING(2),
/** /**

View File

@@ -20,6 +20,7 @@ import io.emeraldpay.dshackle.cache.Caches
import io.emeraldpay.dshackle.data.BlockId import io.emeraldpay.dshackle.data.BlockId
import io.emeraldpay.dshackle.data.DefaultContainer import io.emeraldpay.dshackle.data.DefaultContainer
import io.emeraldpay.dshackle.data.TxId import io.emeraldpay.dshackle.data.TxId
import io.emeraldpay.dshackle.upstream.Head
import io.emeraldpay.dshackle.upstream.RequestPostprocessor import io.emeraldpay.dshackle.upstream.RequestPostprocessor
import io.emeraldpay.etherjar.rpc.json.TransactionReceiptJson import io.emeraldpay.etherjar.rpc.json.TransactionReceiptJson
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
@@ -32,7 +33,7 @@ class CacheRequested(
private val log = LoggerFactory.getLogger(CacheRequested::class.java) private val log = LoggerFactory.getLogger(CacheRequested::class.java)
} }
override fun onReceive(method: String, params: List<Any>, json: ByteArray) { override fun onReceive(method: String, params: List<Any?>, json: ByteArray) {
try { try {
if (method == "eth_getTransactionReceipt") { if (method == "eth_getTransactionReceipt") {
cacheTxReceipt(params, json) cacheTxReceipt(params, json)
@@ -42,7 +43,7 @@ class CacheRequested(
} }
} }
fun cacheTxReceipt(params: List<Any>, json: ByteArray) { fun cacheTxReceipt(params: List<Any?>, json: ByteArray) {
if (params.size != 1) { if (params.size != 1) {
return return
} }

View File

@@ -41,6 +41,7 @@ open class EthereumMultistream(
private var head: Head? = null private var head: Head? = null
private val reader: EthereumReader = EthereumReader(this, this.caches, getMethodsFactory()) private val reader: EthereumReader = EthereumReader(this, this.caches, getMethodsFactory())
private val subscribe = EthereumSubscribe(this)
init { init {
this.init() this.init()
@@ -122,4 +123,7 @@ open class EthereumMultistream(
return Mono.just(NativeCallRouter(reader, getMethods(), getHead())) return Mono.just(NativeCallRouter(reader, getMethods(), getHead()))
} }
open fun getSubscribe(): EthereumSubscribe {
return subscribe
}
} }

View File

@@ -23,7 +23,9 @@ import io.emeraldpay.dshackle.cache.HeightByHashAdding
import io.emeraldpay.dshackle.data.* import io.emeraldpay.dshackle.data.*
import io.emeraldpay.dshackle.reader.* import io.emeraldpay.dshackle.reader.*
import io.emeraldpay.dshackle.upstream.Multistream import io.emeraldpay.dshackle.upstream.Multistream
import io.emeraldpay.dshackle.upstream.Selector
import io.emeraldpay.dshackle.upstream.calls.CallMethods import io.emeraldpay.dshackle.upstream.calls.CallMethods
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcRequest
import io.emeraldpay.etherjar.domain.Address import io.emeraldpay.etherjar.domain.Address
import io.emeraldpay.etherjar.domain.BlockHash import io.emeraldpay.etherjar.domain.BlockHash
import io.emeraldpay.etherjar.domain.TransactionId import io.emeraldpay.etherjar.domain.TransactionId
@@ -142,7 +144,14 @@ open class EthereumReader(
} }
fun receipts(): Reader<TxId, ByteArray> { fun receipts(): Reader<TxId, ByteArray> {
return caches.getReceipts() //TODO put into cache
val requested = RekeyingReader(
{ txid: TxId -> txid.toHexWithPrefix() },
RpcReader.basicRequest(up, "eth_getTransactionReceipt"))
return CompoundReader(
caches.getReceipts(),
requested
)
} }
fun heightByHash(): Reader<BlockId, Long> { fun heightByHash(): Reader<BlockId, Long> {

View File

@@ -0,0 +1,97 @@
package io.emeraldpay.dshackle.upstream.ethereum
import io.emeraldpay.dshackle.upstream.ethereum.subscribe.ConnectLogs
import io.emeraldpay.dshackle.upstream.ethereum.subscribe.ConnectNewHeads
import io.emeraldpay.dshackle.upstream.ethereum.subscribe.ConnectSyncing
import io.emeraldpay.dshackle.upstream.ethereum.subscribe.ProduceLogs
import io.emeraldpay.etherjar.domain.Address
import io.emeraldpay.etherjar.hex.Hex32
import org.slf4j.LoggerFactory
import reactor.core.publisher.Flux
open class EthereumSubscribe(
val upstream: EthereumMultistream
) {
companion object {
private val log = LoggerFactory.getLogger(EthereumSubscribe::class.java)
}
private val newHeads = ConnectNewHeads(upstream)
private val logs = ConnectLogs(upstream)
private val syncing = ConnectSyncing(upstream)
@Suppress("UNCHECKED_CAST")
open fun subscribe(method: String, params: Any?): Flux<out Any> {
if (method == "newHeads") {
return newHeads.connect()
}
if (method == "logs") {
val paramsMap = try {
if (params != null && Map::class.java.isAssignableFrom(params.javaClass)) {
readLogsRequest(params as Map<String, Any?>)
} else {
LogsRequest(emptyList(), emptyList())
}
} catch (t: Throwable) {
return Flux.error(UnsupportedOperationException("Invalid parameter for $method. Error: ${t.message}"))
}
return logs.start(paramsMap.address, paramsMap.topics)
}
if (method == "syncing") {
return syncing.connect()
}
return Flux.error(UnsupportedOperationException("Method $method is not supported"))
}
data class LogsRequest(
val address: List<Address>,
val topics: List<Hex32>
)
fun readLogsRequest(params: Map<String, Any?>): LogsRequest {
val addresses: List<Address> = if (params.containsKey("address")) {
when (val address = params["address"]) {
is String -> try {
listOf(Address.from(address))
} catch (t: Throwable) {
log.debug("Ignore invalid address: $address with error ${t.message}")
emptyList()
}
is Collection<*> -> address.mapNotNull {
try {
Address.from(it.toString())
} catch (t: Throwable) {
log.debug("Ignore invalid address: $address with error ${t.message}")
null
}
}
else -> throw IllegalArgumentException("Invalid type of address field. Must be string or list of strings")
}
} else {
emptyList()
}
val topics: List<Hex32> = if (params.containsKey("topics")) {
when (val topics = params["topics"]) {
is String -> try {
listOf(Hex32.from(topics))
} catch (t: Throwable) {
log.debug("Ignore invalid topic: $topics with error ${t.message}")
emptyList()
}
is Collection<*> -> topics.mapNotNull {
try {
Hex32.from(it.toString())
} catch (t: Throwable) {
log.debug("Ignore invalid topic: $topics with error ${t.message}")
null
}
}
else -> throw IllegalArgumentException("Invalid type of topics field. Must be string or list of strings")
}
} else {
emptyList()
}
return LogsRequest(addresses, topics)
}
}

View File

@@ -24,10 +24,8 @@ 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.DefaultUpstream
import io.emeraldpay.dshackle.upstream.UpstreamAvailability import io.emeraldpay.dshackle.upstream.UpstreamAvailability
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcRequest import io.emeraldpay.dshackle.upstream.rpcclient.*
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcResponse import io.emeraldpay.etherjar.rpc.RpcResponseError
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.netty.buffer.ByteBuf import io.netty.buffer.ByteBuf
@@ -365,6 +363,7 @@ class EthereumWsFactory(
Flux.from(rpcReceive.asFlux()) Flux.from(rpcReceive.asFlux())
.doOnSubscribe { sendRpc(request) } .doOnSubscribe { sendRpc(request) }
.filter { resp -> resp.id.asNumber() == expectedId } .filter { resp -> resp.id.asNumber() == expectedId }
.take(Defaults.timeout)
.take(1) .take(1)
.singleOrEmpty() .singleOrEmpty()
.doOnNext { .doOnNext {
@@ -374,6 +373,12 @@ class EthereumWsFactory(
rpcMetrics?.errors?.increment() rpcMetrics?.errors?.increment()
} }
.map { it.copyWithId(JsonRpcResponse.Id.from(originalId)) } .map { it.copyWithId(JsonRpcResponse.Id.from(originalId)) }
.defaultIfEmpty(
JsonRpcResponse(null,
JsonRpcError(RpcResponseError.CODE_INTERNAL_ERROR, "Response not received from WebSocket"),
JsonRpcResponse.Id.from(originalId)
)
)
} }
} }

View File

@@ -0,0 +1,151 @@
/**
* 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.subscribe
import io.emeraldpay.dshackle.data.BlockContainer
import io.emeraldpay.dshackle.data.BlockId
import io.emeraldpay.dshackle.data.TxId
import io.emeraldpay.dshackle.upstream.Head
import io.emeraldpay.dshackle.upstream.ethereum.EthereumMultistream
import org.slf4j.LoggerFactory
import reactor.core.publisher.Flux
import reactor.core.scheduler.Schedulers
import java.time.Duration
import java.util.*
import java.util.concurrent.locks.ReentrantLock
import java.util.concurrent.locks.ReentrantReadWriteLock
import kotlin.concurrent.read
import kotlin.concurrent.withLock
import kotlin.concurrent.write
class ConnectBlockUpdates(
private val upstream: EthereumMultistream
) {
companion object {
private val log = LoggerFactory.getLogger(ConnectBlockUpdates::class.java)
private const val HISTORY_LIMIT = 6 * 3
}
/**
* Need to keep history of few last blocks in case we have got a conflicting blocks on the same height.
* In this case it produces a list of updates for transactions that are missing from the new version of the block.
*/
private val history = LinkedList<BlockContainer>()
private val historyUpdateLock = ReentrantReadWriteLock()
private var connected: Flux<Update>? = null
private val connectLock = ReentrantLock()
fun connect(): Flux<Update> {
val current = connected
if (current != null) {
return current
}
connectLock.withLock {
val currentRecheck = connected
if (currentRecheck != null) {
return currentRecheck
}
val created = extract(upstream.getHead())
.publishOn(Schedulers.boundedElastic())
.publish()
.refCount(1, Duration.ofSeconds(60))
.doFinally {
//forget it on disconnect, so next time it's recreated
connected = null
}
connected = created
return created
}
}
fun extract(head: Head): Flux<Update> {
return head.getFlux()
.flatMap(this@ConnectBlockUpdates::extract)
}
fun extract(block: BlockContainer): Flux<Update> {
val prev = findPrevious(block)
remember(block)
val removed = if (prev != null) {
whenReplaced(prev)
} else {
Flux.empty()
}
val added = extractUpdates(block)
return Flux.concat(removed, added)
}
fun findPrevious(block: BlockContainer): BlockContainer? {
historyUpdateLock.read {
val existing = history.find { it.height == block.height }
if (existing != null) {
historyUpdateLock.write {
history.removeIf { it.hash == existing.hash }
}
}
return existing
}
}
fun remember(block: BlockContainer) {
historyUpdateLock.write {
history.add(block)
if (history.size > HISTORY_LIMIT) {
history.removeFirst()
}
}
}
/**
* Produce updates for transactions when a block is replaces with a different one on the same height.
*/
fun whenReplaced(prev: BlockContainer): Flux<Update> {
return Flux.fromIterable(prev.transactions).map {
Update(
prev.hash,
prev.height,
UpdateType.DROP,
it
)
}
}
fun extractUpdates(block: BlockContainer): Flux<Update> {
return Flux.fromIterable(block.transactions)
.map {
Update(
block.hash,
block.height,
UpdateType.NEW,
it
)
}
}
data class Update(
val blockHash: BlockId,
val blockNumber: Long,
val type: UpdateType,
val transactionId: TxId,
)
enum class UpdateType {
NEW,
DROP
}
}

View File

@@ -0,0 +1,69 @@
/**
* 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.subscribe
import io.emeraldpay.dshackle.upstream.ethereum.EthereumMultistream
import io.emeraldpay.dshackle.upstream.ethereum.subscribe.json.LogMessage
import io.emeraldpay.etherjar.domain.Address
import io.emeraldpay.etherjar.hex.Hex32
import io.emeraldpay.etherjar.hex.HexDataComparator
import org.slf4j.LoggerFactory
import reactor.core.publisher.Flux
import java.util.function.Function
class ConnectLogs(
upstream: EthereumMultistream,
private val connectBlockUpdates: ConnectBlockUpdates,
) {
companion object {
private val log = LoggerFactory.getLogger(ConnectLogs::class.java)
private val ADDR_COMPARATOR = HexDataComparator()
private val TOPIC_COMPARATOR = HexDataComparator()
}
constructor(upstream: EthereumMultistream) : this(upstream, ConnectBlockUpdates(upstream))
private val produceLogs = ProduceLogs(upstream)
fun start(): Flux<LogMessage> {
return produceLogs.produce(connectBlockUpdates.connect())
}
fun start(addresses: List<Address>, topics: List<Hex32>): Flux<LogMessage> {
// shortcut to the whole output if we don't have any filters
if (addresses.isEmpty() && topics.isEmpty()) {
return start()
}
// filtered output
return start()
.transform(filtered(addresses, topics))
}
fun filtered(addresses: List<Address>, topics: List<Hex32>): Function<Flux<LogMessage>, Flux<LogMessage>> {
//sort search criteria to use binary search later
val sortedAddresses: List<Address> = addresses.sortedWith(ADDR_COMPARATOR)
val sortedTopics: List<Hex32> = topics.sortedWith(TOPIC_COMPARATOR)
return Function { logs ->
logs.filter {
val goodAddress = sortedAddresses.isEmpty() || sortedAddresses.binarySearch(it.address, ADDR_COMPARATOR) >= 0
val goodTopic = sortedTopics.isEmpty() || (it.topics.isNotEmpty() && sortedTopics.binarySearch(it.topics[0], TOPIC_COMPARATOR) >= 0)
goodAddress && goodTopic
}
}
}
}

View File

@@ -0,0 +1,64 @@
/**
* 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.subscribe
import io.emeraldpay.dshackle.upstream.ethereum.EthereumMultistream
import io.emeraldpay.dshackle.upstream.ethereum.subscribe.json.NewHeadMessage
import org.slf4j.LoggerFactory
import reactor.core.publisher.Flux
import reactor.core.scheduler.Schedulers
import java.time.Duration
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
/**
* Connects/reconnects to the upstream to produce NewHeads messages
*/
class ConnectNewHeads(
private val upstream: EthereumMultistream
) {
companion object {
private val log = LoggerFactory.getLogger(ConnectNewHeads::class.java)
}
private var connected: Flux<NewHeadMessage>? = null
private val connectLock = ReentrantLock()
fun connect(): Flux<NewHeadMessage> {
val current = connected
if (current != null) {
return current
}
connectLock.withLock {
val currentRecheck = connected
if (currentRecheck != null) {
return currentRecheck
}
val created = ProduceNewHeads(upstream.getHead())
.start()
.publishOn(Schedulers.boundedElastic())
.publish()
.refCount(1, Duration.ofSeconds(60))
.doFinally {
//forget it on disconnect, so next time it's recreated
connected = null
}
connected = created
return created
}
}
}

View File

@@ -0,0 +1,59 @@
/**
* 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.subscribe
import io.emeraldpay.dshackle.upstream.UpstreamAvailability
import io.emeraldpay.dshackle.upstream.ethereum.EthereumMultistream
import org.slf4j.LoggerFactory
import reactor.core.publisher.Flux
import java.time.Duration
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
class ConnectSyncing(
private val upstream: EthereumMultistream
) {
companion object {
private val log = LoggerFactory.getLogger(ConnectSyncing::class.java)
}
private var connected: Flux<Boolean>? = null
private val connectLock = ReentrantLock()
fun connect(): Flux<Boolean> {
val current = connected
if (current != null) {
return current
}
connectLock.withLock {
val currentRecheck = connected
if (currentRecheck != null) {
return currentRecheck
}
val created = upstream.observeStatus()
.map { it != UpstreamAvailability.OK }
.publish()
.refCount(1, Duration.ofSeconds(60))
.doFinally {
//forget it on disconnect, so next time it's recreated
connected = null
}
connected = created
return created
}
}
}

View File

@@ -0,0 +1,107 @@
/**
* 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.subscribe
import com.google.common.cache.CacheBuilder
import io.emeraldpay.dshackle.Global
import io.emeraldpay.dshackle.data.BlockId
import io.emeraldpay.dshackle.data.TxId
import io.emeraldpay.dshackle.reader.Reader
import io.emeraldpay.dshackle.upstream.ethereum.EthereumMultistream
import io.emeraldpay.dshackle.upstream.ethereum.subscribe.json.LogMessage
import io.emeraldpay.etherjar.hex.HexData
import io.emeraldpay.etherjar.rpc.json.TransactionReceiptJson
import org.slf4j.LoggerFactory
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import reactor.kotlin.core.publisher.switchIfEmpty
import java.util.concurrent.TimeUnit
class ProduceLogs(
private val receipts: Reader<TxId, ByteArray>
) {
companion object {
private val log = LoggerFactory.getLogger(ProduceLogs::class.java)
}
constructor(upstream: EthereumMultistream) : this(upstream.getReader().receipts())
private val objectMapper = Global.objectMapper
// need to keep history of recent messages in case they get removed. cannot rely on
// any other cache or upstream because if when it gets removed it's unavailable in any other source
private val oldMessages = CacheBuilder.newBuilder()
.expireAfterWrite(5, TimeUnit.HOURS)
.build<LogReference, List<LogMessage>>()
fun produce(block: Flux<ConnectBlockUpdates.Update>): Flux<LogMessage> {
return block.flatMap { update ->
if (update.type == ConnectBlockUpdates.UpdateType.DROP) {
produceRemoved(update)
} else {
produceAdded(update)
}
}
}
fun produceRemoved(update: ConnectBlockUpdates.Update): Flux<LogMessage> {
val old = oldMessages.getIfPresent(LogReference(update.blockHash, update.transactionId))
if (old == null) {
log.warn("No old message to produce removal messages for tx ${update.transactionId} at block ${update.blockHash}")
return Flux.empty()
}
return Flux.fromIterable(old)
.map { it.copy(removed = true) }
}
fun produceAdded(update: ConnectBlockUpdates.Update): Flux<LogMessage> {
return receipts.read(update.transactionId)
.switchIfEmpty {
log.warn("Cannot find receipt for tx ${update.transactionId}")
Mono.empty()
}
.map { objectMapper.readValue(it, TransactionReceiptJson::class.java) }
.flatMapMany { receipt ->
try {
val messages = receipt.logs
.map { txlog ->
LogMessage(
txlog.address,
txlog.blockHash,
txlog.blockNumber,
txlog.data ?: HexData.empty(),
txlog.logIndex,
txlog.topics,
txlog.transactionHash,
txlog.transactionIndex,
false
)
}
oldMessages.put(LogReference(update.blockHash, update.transactionId), messages)
Flux.fromIterable(messages)
} catch (t: Throwable) {
log.warn("Invalid Receipt ${update.transactionId}. ${t.javaClass}: ${t.message}")
Flux.empty<LogMessage>()
}
}
}
private data class LogReference(
val block: BlockId,
val tx: TxId
)
}

View File

@@ -0,0 +1,66 @@
/**
* 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.subscribe
import io.emeraldpay.dshackle.Global
import io.emeraldpay.dshackle.upstream.Head
import io.emeraldpay.dshackle.upstream.ethereum.subscribe.json.NewHeadMessage
import io.emeraldpay.etherjar.rpc.json.BlockJson
import io.emeraldpay.etherjar.rpc.json.TransactionRefJson
import org.slf4j.LoggerFactory
import reactor.core.publisher.Flux
/**
* Produces NewHead messages by transforming blocks received from Head
* @see Head
* @see NewHeadMessage
*/
class ProduceNewHeads(
val head: Head
) {
companion object {
private val log = LoggerFactory.getLogger(ProduceNewHeads::class.java)
}
private val objectMapper = Global.objectMapper
fun start(): Flux<NewHeadMessage> {
return head.getFlux()
.map {
if (it.parsed != null) {
it.parsed as BlockJson<TransactionRefJson>
} else {
objectMapper.readValue(it.json, BlockJson::class.java)
}
}
.map { block ->
NewHeadMessage(
block.number,
block.hash,
block.parentHash,
block.timestamp,
block.difficulty,
block.gasLimit,
block.gasUsed,
block.logsBloom,
block.miner,
block.baseFeePerGas?.amount
)
}
}
}

View File

@@ -0,0 +1,44 @@
/**
* 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.subscribe.json
import com.fasterxml.jackson.databind.annotation.JsonSerialize
import io.emeraldpay.etherjar.domain.Address
import io.emeraldpay.etherjar.domain.BlockHash
import io.emeraldpay.etherjar.domain.TransactionId
import io.emeraldpay.etherjar.hex.Hex32
import io.emeraldpay.etherjar.hex.HexData
import io.emeraldpay.etherjar.rpc.json.HexDataSerializer
data class LogMessage(
@get:JsonSerialize(using = HexDataSerializer::class)
val address: Address,
@get:JsonSerialize(using = HexDataSerializer::class)
val blockHash: BlockHash,
@get:JsonSerialize(using = NumberAsHexSerializer::class)
val blockNumber: Long,
@get:JsonSerialize(using = HexDataSerializer::class)
val data: HexData,
@get:JsonSerialize(using = NumberAsHexSerializer::class)
val logIndex: Long,
@get:JsonSerialize(contentUsing = HexDataSerializer::class)
val topics: List<Hex32>,
@get:JsonSerialize(using = HexDataSerializer::class)
val transactionHash: TransactionId,
@get:JsonSerialize(using = NumberAsHexSerializer::class)
val transactionIndex: Long,
val removed: Boolean
)

View File

@@ -0,0 +1,54 @@
/**
* 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.subscribe.json
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.databind.annotation.JsonSerialize
import io.emeraldpay.etherjar.domain.Address
import io.emeraldpay.etherjar.domain.BlockHash
import io.emeraldpay.etherjar.domain.Bloom
import io.emeraldpay.etherjar.rpc.json.HexDataSerializer
import java.math.BigInteger
import java.time.Instant
/**
* Common fields for newHeads event. IT's different from Block JSON and doesn't include many fields, most notable is
* list of transactions. Also, our JSON doesn't include rarely used fields such as extraData, sha3uncles, stateRoot,
* transactionRoot and some others.
*/
data class NewHeadMessage(
@get:JsonSerialize(using = NumberAsHexSerializer::class)
val number: Long,
@get:JsonSerialize(using = HexDataSerializer::class)
val hash: BlockHash,
@get:JsonSerialize(using = HexDataSerializer::class)
val parentHash: BlockHash,
@get:JsonSerialize(using = TimestampSerializer::class)
val timestamp: Instant,
@get:JsonSerialize(using = NumberAsHexSerializer::class)
val difficulty: BigInteger,
@get:JsonSerialize(using = NumberAsHexSerializer::class)
val gasLimit: Long,
@get:JsonSerialize(using = NumberAsHexSerializer::class)
val gasUsed: Long,
@get:JsonSerialize(using = HexDataSerializer::class)
val logsBloom: Bloom,
@get:JsonSerialize(using = HexDataSerializer::class)
val miner: Address,
@get:JsonSerialize(using = NumberAsHexSerializer::class)
@get:JsonInclude(JsonInclude.Include.NON_NULL)
val baseFeePerGas: BigInteger?
)

View File

@@ -0,0 +1,42 @@
/**
* 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.subscribe.json
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.JsonSerializer
import com.fasterxml.jackson.databind.SerializerProvider
import io.emeraldpay.etherjar.hex.HexQuantity
import java.math.BigInteger
/**
* Encodes numeric values as hex string prefixed with <code>0x</code>, per Ethereum standard.
*/
class NumberAsHexSerializer : JsonSerializer<Number>() {
override fun serialize(value: Number?, gen: JsonGenerator, serializers: SerializerProvider) {
if (value == null) {
gen.writeNull()
return
}
val hex = if (value is BigInteger) {
HexQuantity.from(value)
} else {
HexQuantity.from(value.toLong())
}
gen.writeString(hex.toHex())
}
}

View File

@@ -0,0 +1,39 @@
/**
* 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.subscribe.json
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.JsonSerializer
import com.fasterxml.jackson.databind.SerializerProvider
import java.time.Instant
/**
* Encodes timestamps as seconds from epoch, written as hex string.
* @see NumberAsHexSerializer
*/
class TimestampSerializer : JsonSerializer<Instant>() {
private val numberAsHex = NumberAsHexSerializer()
override fun serialize(value: Instant?, gen: JsonGenerator, serializers: SerializerProvider) {
if (value == null) {
gen.writeNull()
return
}
numberAsHex.serialize(value.epochSecond, gen, serializers)
}
}

View File

@@ -19,12 +19,17 @@ import com.fasterxml.jackson.databind.ObjectMapper
import io.emeraldpay.dshackle.Global import io.emeraldpay.dshackle.Global
import io.emeraldpay.dshackle.data.BlockContainer import io.emeraldpay.dshackle.data.BlockContainer
import io.emeraldpay.dshackle.data.BlockId import io.emeraldpay.dshackle.data.BlockId
import io.emeraldpay.dshackle.data.DefaultContainer
import io.emeraldpay.dshackle.data.TxContainer import io.emeraldpay.dshackle.data.TxContainer
import io.emeraldpay.dshackle.data.TxId
import io.emeraldpay.dshackle.test.TestingCommons import io.emeraldpay.dshackle.test.TestingCommons
import io.emeraldpay.dshackle.upstream.Head
import io.emeraldpay.etherjar.domain.Address
import io.emeraldpay.etherjar.domain.BlockHash import io.emeraldpay.etherjar.domain.BlockHash
import io.emeraldpay.etherjar.domain.TransactionId import io.emeraldpay.etherjar.domain.TransactionId
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.TransactionJson
import io.emeraldpay.etherjar.rpc.json.TransactionReceiptJson
import io.emeraldpay.etherjar.rpc.json.TransactionRefJson import io.emeraldpay.etherjar.rpc.json.TransactionRefJson
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
import spock.lang.Specification import spock.lang.Specification
@@ -227,4 +232,36 @@ class CachesSpec extends Specification {
1 * blocksCache.read(block.hash) >> Mono.just(block) 1 * blocksCache.read(block.hash) >> Mono.just(block)
1 * txRedisCache.add(TxContainer.from(tx1), block) >> Mono.just(1).then() 1 * txRedisCache.add(TxContainer.from(tx1), block) >> Mono.just(1).then()
} }
def "Put receipt into mem cache"() {
setup:
def receipt = new TransactionReceiptJson().tap {
transactionHash = TransactionId.from("0xc7529e79f78f58125abafeaea01fe3abdc6f45c173d5dfb36716cbc526e5b2d1")
blockHash = BlockHash.from("0x48249c81bfced2e6fe2536126471b73d83c4f21de75f88a16feb57cc566b991b")
blockNumber = 0xccf6e2
from = Address.from("0x3a1428354c99b119d891a30d326bad92e36e896a")
logs = []
}
def receiptContainer = new DefaultContainer(
TxId.from(receipt.transactionHash),
BlockId.from(receipt.blockHash),
receipt.blockNumber,
Global.objectMapper.writeValueAsBytes(receipt),
receipt
)
ReceiptMemCache receiptMemCache = Mock()
def caches = Caches.newBuilder()
.setReceipts(receiptMemCache)
.build()
Head head = Mock()
caches.setHead(head)
when:
caches.cacheReceipt(Caches.Tag.REQUESTED, receiptContainer)
then:
1 * head.getCurrentHeight() >> 0xccf6e2
1 * receiptMemCache.acceptsRecentBlocks(0) >> true
1 * receiptMemCache.add(receiptContainer)
}
} }

View File

@@ -0,0 +1,100 @@
/**
* 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.cache
import com.fasterxml.jackson.databind.ObjectMapper
import io.emeraldpay.dshackle.Global
import io.emeraldpay.dshackle.data.BlockContainer
import io.emeraldpay.dshackle.data.BlockId
import io.emeraldpay.dshackle.data.DefaultContainer
import io.emeraldpay.dshackle.data.TxId
import io.emeraldpay.etherjar.domain.Address
import io.emeraldpay.etherjar.domain.BlockHash
import io.emeraldpay.etherjar.domain.TransactionId
import io.emeraldpay.etherjar.rpc.json.TransactionReceiptJson
import spock.lang.Specification
import java.time.Instant
class ReceiptMemCacheSpec extends Specification {
ObjectMapper objectMapper = Global.objectMapper
def "Add and read"() {
setup:
def cache = new ReceiptMemCache()
def receipt = new TransactionReceiptJson().tap {
transactionHash = TransactionId.from("0xc7529e79f78f58125abafeaea01fe3abdc6f45c173d5dfb36716cbc526e5b2d1")
blockHash = BlockHash.from("0x48249c81bfced2e6fe2536126471b73d83c4f21de75f88a16feb57cc566b991b")
blockNumber = 0xccf6e2
from = Address.from("0x3a1428354c99b119d891a30d326bad92e36e896a")
logs = []
}
def receiptContainer = new DefaultContainer(
TxId.from(receipt.transactionHash),
BlockId.from(receipt.blockHash),
receipt.blockNumber,
objectMapper.writeValueAsBytes(receipt),
receipt
)
when:
cache.add(receiptContainer)
def act = cache.read(TxId.from(receipt.transactionHash)).block()
then:
act != null
objectMapper.readValue(act, TransactionReceiptJson.class) == receipt
}
def "Evict by block"() {
setup:
def cache = new ReceiptMemCache()
def receipt = new TransactionReceiptJson().tap {
transactionHash = TransactionId.from("0xc7529e79f78f58125abafeaea01fe3abdc6f45c173d5dfb36716cbc526e5b2d1")
blockHash = BlockHash.from("0x48249c81bfced2e6fe2536126471b73d83c4f21de75f88a16feb57cc566b991b")
blockNumber = 0xccf6e2
from = Address.from("0x3a1428354c99b119d891a30d326bad92e36e896a")
logs = []
}
def receiptContainer = new DefaultContainer(
TxId.from(receipt.transactionHash),
BlockId.from(receipt.blockHash),
receipt.blockNumber,
objectMapper.writeValueAsBytes(receipt),
receipt
)
def blockContainer = new BlockContainer(
receipt.blockNumber, BlockId.from(receipt.blockHash),
BigInteger.ONE,
Instant.now(),
false,
"{}".bytes,
null,
[TxId.from(receipt.transactionHash)]
)
when:
cache.add(receiptContainer)
cache.evict(blockContainer)
def act = cache.read(TxId.from(receipt.transactionHash)).block()
then:
act == null
}
}

View File

@@ -72,21 +72,20 @@ class CompoundReaderSpec extends Specification {
.verify(Duration.ofSeconds(1)) .verify(Duration.ofSeconds(1))
} }
def "Return second"() { def "Doesn't call others after getting first"() {
setup: setup:
def reader = new CompoundReader<String, String>(reader3, reader2) def call2 = false
when: def reader2 = new Reader<String, String>() {
def act = reader.read("test") @Override
then: Mono<String> read(String key) {
StepVerifier.create(act) call2 = true
.expectNext("test-2") return Mono.just("test-2").delaySubscription(Duration.ofMillis(200))
.expectComplete() }
.verify(Duration.ofSeconds(1)) }
} def reader = new CompoundReader<String, String>(
reader1,
def "Return third"() { reader2
setup: )
def reader = new CompoundReader<String, String>(reader3, reader2, reader1)
when: when:
def act = reader.read("test") def act = reader.read("test")
then: then:
@@ -94,16 +93,29 @@ class CompoundReaderSpec extends Specification {
.expectNext("test-1") .expectNext("test-1")
.expectComplete() .expectComplete()
.verify(Duration.ofSeconds(1)) .verify(Duration.ofSeconds(1))
!call2
} }
def "Ignore empty"() { def "Return first even if it's slow"() {
setup: setup:
def reader = new CompoundReader<String, String>(reader3, reader1Empty, reader2, reader1Empty) def reader = new CompoundReader<String, String>(reader3, reader2)
when: when:
def act = reader.read("test") def act = reader.read("test")
then: then:
StepVerifier.create(act) StepVerifier.create(act)
.expectNext("test-2") .expectNext("test-3")
.expectComplete()
.verify(Duration.ofSeconds(1))
}
def "Ignore empty"() {
setup:
def reader = new CompoundReader<String, String>(reader1Empty, reader3, reader2, reader1Empty)
when:
def act = reader.read("test")
then:
StepVerifier.create(act)
.expectNext("test-3")
.expectComplete() .expectComplete()
.verify(Duration.ofSeconds(1)) .verify(Duration.ofSeconds(1))
} }

View File

@@ -0,0 +1,91 @@
/**
* 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.rpc
import com.google.protobuf.ByteString
import io.emeraldpay.api.proto.BlockchainOuterClass
import io.emeraldpay.dshackle.test.MultistreamHolderMock
import io.emeraldpay.dshackle.upstream.ethereum.EthereumMultistream
import io.emeraldpay.dshackle.upstream.ethereum.EthereumSubscribe
import io.emeraldpay.grpc.Chain
import reactor.core.publisher.Flux
import reactor.test.StepVerifier
import spock.lang.Specification
import java.time.Duration
class NativeSubscribeSpec extends Specification {
def "Call with empty params when not provided"() {
setup:
def subscribe = Mock(EthereumSubscribe) {
1 * it.subscribe("newHeads", null) >> Flux.just("{}")
}
def up = Mock(EthereumMultistream) {
1 * it.getSubscribe() >> subscribe
}
def nativeSubscribe = new NativeSubscribe(new MultistreamHolderMock(Chain.ETHEREUM, up))
def call = BlockchainOuterClass.NativeSubscribeRequest.newBuilder()
.setChainValue(Chain.ETHEREUM.id)
.setMethod("newHeads")
.build()
when:
def act = nativeSubscribe.start(call)
then:
StepVerifier.create(act)
.expectNext("{}")
.expectComplete()
.verify(Duration.ofSeconds(1))
}
def "Call with params when provided"() {
setup:
def subscribe = Mock(EthereumSubscribe) {
1 * it.subscribe("logs", { params ->
println("params: $params")
def ok = params instanceof Map &&
params["address"] == "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2" &&
params["topics"] instanceof List &&
params["topics"][0] == "0x7fcf532c15f0a6db0bd6d0e038bea71d30d808c7d98cb3bf7268a95bf5081b65"
println("ok: $ok")
ok
}) >> Flux.just("{}")
}
def up = Mock(EthereumMultistream) {
1 * it.getSubscribe() >> subscribe
}
def nativeSubscribe = new NativeSubscribe(new MultistreamHolderMock(Chain.ETHEREUM, up))
def call = BlockchainOuterClass.NativeSubscribeRequest.newBuilder()
.setChainValue(Chain.ETHEREUM.id)
.setMethod("logs")
.setPayload(ByteString.copyFromUtf8(
'{"address": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", ' +
'"topics": ["0x7fcf532c15f0a6db0bd6d0e038bea71d30d808c7d98cb3bf7268a95bf5081b65"]}'
))
.build()
when:
def act = nativeSubscribe.start(call)
then:
StepVerifier.create(act)
.expectNext("{}")
.expectComplete()
.verify(Duration.ofSeconds(1))
}
}

View File

@@ -52,6 +52,7 @@ 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.concurrent.atomic.AtomicInteger
import java.util.function.BiFunction import java.util.function.BiFunction
import java.util.function.Consumer import java.util.function.Consumer
import java.util.function.Predicate import java.util.function.Predicate
@@ -63,6 +64,7 @@ class EthereumApiMock implements Reader<JsonRpcRequest, JsonRpcResponse> {
private final ObjectMapper objectMapper = Global.objectMapper private final ObjectMapper objectMapper = Global.objectMapper
String id = "default" String id = "default"
AtomicInteger calls = new AtomicInteger(0)
EthereumApiMock() { EthereumApiMock() {
} }
@@ -83,6 +85,7 @@ class EthereumApiMock implements Reader<JsonRpcRequest, JsonRpcResponse> {
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
JsonRpcError error = null JsonRpcError error = null
calls.incrementAndGet()
if (predefined != null) { if (predefined != null) {
if (predefined.exception != null) { if (predefined.exception != null) {
predefined.onCalled() predefined.onCalled()

View File

@@ -17,12 +17,18 @@
package io.emeraldpay.dshackle.upstream package io.emeraldpay.dshackle.upstream
import io.emeraldpay.dshackle.cache.Caches import io.emeraldpay.dshackle.cache.Caches
import io.emeraldpay.dshackle.config.UpstreamsConfig
import io.emeraldpay.dshackle.quorum.AlwaysQuorum import io.emeraldpay.dshackle.quorum.AlwaysQuorum
import io.emeraldpay.dshackle.reader.Reader
import io.emeraldpay.dshackle.test.EthereumUpstreamMock import io.emeraldpay.dshackle.test.EthereumUpstreamMock
import io.emeraldpay.dshackle.test.TestingCommons import io.emeraldpay.dshackle.test.TestingCommons
import io.emeraldpay.dshackle.upstream.calls.DirectCallMethods import io.emeraldpay.dshackle.upstream.calls.DirectCallMethods
import io.emeraldpay.dshackle.upstream.ethereum.EthereumMultistream import io.emeraldpay.dshackle.upstream.ethereum.EthereumMultistream
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcRequest
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcResponse
import io.emeraldpay.grpc.Chain import io.emeraldpay.grpc.Chain
import org.jetbrains.annotations.NotNull
import reactor.core.publisher.Mono
import spock.lang.Specification import spock.lang.Specification
import java.time.Duration import java.time.Duration
@@ -163,4 +169,61 @@ class MultistreamSpec extends Specification {
then: then:
!act !act
} }
def "Call postprocess after api use"() {
setup:
def request = new JsonRpcRequest("test_foo", [1], 1)
def api = TestingCommons.api()
api.answer("test_foo", [1], "test")
def postprocessor = Mock(RequestPostprocessor)
def up = TestingCommons.upstream(api)
def multistream = new TestMultistream([up], postprocessor)
when:
def rdr = multistream.getDirectApi(Selector.empty).block(Duration.ofSeconds(1))
def act = rdr.read(request).block(Duration.ofSeconds(1))
then:
act != null
act.hasResult()
act.resultAsProcessedString == "test"
1 * postprocessor.onReceive("test_foo", [1], "\"test\"".bytes)
}
class TestMultistream extends Multistream {
TestMultistream(List<Upstream> upstreams, @NotNull RequestPostprocessor postprocessor) {
super(Chain.ETHEREUM, upstreams, Caches.default(), postprocessor)
}
@Override
Mono<Reader<JsonRpcRequest, JsonRpcResponse>> getRoutedApi(@NotNull Selector.Matcher matcher) {
return null
}
@Override
Head updateHead() {
return null
}
@Override
void setHead(@NotNull Head head) {
}
@Override
Head getHead() {
return null
}
@Override
Collection<UpstreamsConfig.Labels> getLabels() {
return null
}
public <T extends Upstream> T cast(Class<T> selfType) {
return this
}
}
} }

View File

@@ -0,0 +1,47 @@
package io.emeraldpay.dshackle.upstream
import io.emeraldpay.dshackle.reader.Reader
import io.emeraldpay.dshackle.test.TestingCommons
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcRequest
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcResponse
import reactor.core.publisher.Mono
import spock.lang.Specification
import java.time.Duration
class RequestPostprocessorSpec extends Specification {
def "Wrappers calls onReceive for a value"() {
setup:
def request = new JsonRpcRequest("test_foo", [1], 1)
def processor = Mock(RequestPostprocessor)
def api = TestingCommons.api()
api.answer("test_foo", [1], "test")
def wrapped = new RequestPostprocessor.Wrapper(api, processor)
when:
def act = wrapped.read(request).block(Duration.ofSeconds(1))
then:
act.hasResult()
act.resultAsProcessedString == "test"
1 * processor.onReceive("test_foo", [1], "\"test\"".bytes)
}
def "Wrappers doesn't call onReceive for no value"() {
setup:
def request = new JsonRpcRequest("test_foo", [1], 1)
def processor = Mock(RequestPostprocessor)
Reader<JsonRpcRequest, JsonRpcResponse> reader = Mock(Reader) {
1 * it.read(request) >> Mono.empty()
}
def wrapped = new RequestPostprocessor.Wrapper(reader, processor)
when:
def act = wrapped.read(request).block(Duration.ofSeconds(1))
then:
act == null
0 * processor.onReceive(_, _, _)
}
}

View File

@@ -17,10 +17,12 @@ package io.emeraldpay.dshackle.upstream.ethereum
import io.emeraldpay.dshackle.cache.BlocksMemCache import io.emeraldpay.dshackle.cache.BlocksMemCache
import io.emeraldpay.dshackle.cache.Caches import io.emeraldpay.dshackle.cache.Caches
import io.emeraldpay.dshackle.cache.ReceiptRedisCache
import io.emeraldpay.dshackle.cache.TxMemCache import io.emeraldpay.dshackle.cache.TxMemCache
import io.emeraldpay.dshackle.data.BlockContainer import io.emeraldpay.dshackle.data.BlockContainer
import io.emeraldpay.dshackle.data.BlockId import io.emeraldpay.dshackle.data.BlockId
import io.emeraldpay.dshackle.data.TxContainer import io.emeraldpay.dshackle.data.TxContainer
import io.emeraldpay.dshackle.data.TxId
import io.emeraldpay.dshackle.test.EthereumUpstreamMock import io.emeraldpay.dshackle.test.EthereumUpstreamMock
import io.emeraldpay.dshackle.test.TestingCommons import io.emeraldpay.dshackle.test.TestingCommons
import io.emeraldpay.dshackle.upstream.Multistream import io.emeraldpay.dshackle.upstream.Multistream
@@ -232,4 +234,47 @@ class EthereumReaderSpec extends Specification {
then: then:
act == Wei.from("0xff") act == Wei.from("0xff")
} }
def "Read receipt from upstream if cache is empty"() {
setup:
def api = TestingCommons.api()
api.answerOnce("eth_getTransactionReceipt", ["0xf85b826fdf98ee0f48f7db001be00472e63ceb056846f4ecac5f0c32878b8ab2"], [
transactionHash: "0xf85b826fdf98ee0f48f7db001be00472e63ceb056846f4ecac5f0c32878b8ab2"
])
EthereumUpstreamMock upstream = new EthereumUpstreamMock(Chain.ETHEREUM, api)
def upstreams = TestingCommons.multistream(upstream)
def reader = new EthereumReader(upstreams, Caches.default(), calls)
reader.start()
when:
def act = reader.receipts().read(TxId.from("0xf85b826fdf98ee0f48f7db001be00472e63ceb056846f4ecac5f0c32878b8ab2")).block()
then:
act != null
new String(act) == '{"transactionHash":"0xf85b826fdf98ee0f48f7db001be00472e63ceb056846f4ecac5f0c32878b8ab2"}'
}
def "Read receipt from cache if available"() {
setup:
def api = TestingCommons.api()
EthereumUpstreamMock upstream = new EthereumUpstreamMock(Chain.ETHEREUM, api)
def upstreams = TestingCommons.multistream(upstream)
def receiptCache = Mock(ReceiptRedisCache) {
1 * it.read(TxId.from("0xf85b826fdf98ee0f48f7db001be00472e63ceb056846f4ecac5f0c32878b8ab2")) >>
Mono.just('{"transactionHash":"0xf85b826fdf98ee0f48f7db001be00472e63ceb056846f4ecac5f0c32878b8ab2"}'.bytes)
}
def cashes = Caches.newBuilder()
.setReceipts(receiptCache)
.build()
def reader = new EthereumReader(upstreams, cashes, calls)
reader.start()
when:
def act = reader.receipts().read(TxId.from("0xf85b826fdf98ee0f48f7db001be00472e63ceb056846f4ecac5f0c32878b8ab2")).block()
then:
act != null
new String(act) == '{"transactionHash":"0xf85b826fdf98ee0f48f7db001be00472e63ceb056846f4ecac5f0c32878b8ab2"}'
api.calls.get() == 0
}
} }

View File

@@ -0,0 +1,173 @@
/**
* 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.test.TestingCommons
import io.emeraldpay.etherjar.domain.Address
import io.emeraldpay.etherjar.hex.Hex32
import spock.lang.Specification
class EthereumSubscribeSpec extends Specification {
def "read empty logs request"() {
setup:
def ethereumSubscribe = new EthereumSubscribe(TestingCommons.emptyMultistream() as EthereumMultistream)
when:
def act = ethereumSubscribe.readLogsRequest([:])
then:
act.address == []
act.topics == []
}
def "read single address logs request"() {
setup:
def ethereumSubscribe = new EthereumSubscribe(TestingCommons.emptyMultistream() as EthereumMultistream)
when:
def act = ethereumSubscribe.readLogsRequest([
address: "0x829bd824b016326a401d083b33d092293333a830"
])
then:
act.address == [
Address.from("0x829bd824b016326a401d083b33d092293333a830")
]
act.topics == []
when:
act = ethereumSubscribe.readLogsRequest([
address: ["0x829bd824b016326a401d083b33d092293333a830"]
])
then:
act.address == [
Address.from("0x829bd824b016326a401d083b33d092293333a830")
]
act.topics == []
}
def "ignores invalid address for logs request"() {
setup:
def ethereumSubscribe = new EthereumSubscribe(TestingCommons.emptyMultistream() as EthereumMultistream)
when:
def act = ethereumSubscribe.readLogsRequest([
address: "829bd824b016326a401d083b33d092293333a830"
])
then:
act.address == []
act.topics == []
}
def "read multi address logs request"() {
setup:
def ethereumSubscribe = new EthereumSubscribe(TestingCommons.emptyMultistream() as EthereumMultistream)
when:
def act = ethereumSubscribe.readLogsRequest([
address: ["0x829bd824b016326a401d083b33d092293333a830", "0x401d083b33d092293333a83829bd824b016326a0"]
])
then:
act.address == [
Address.from("0x829bd824b016326a401d083b33d092293333a830"),
Address.from("0x401d083b33d092293333a83829bd824b016326a0")
]
act.topics == []
}
def "read single topic logs request"() {
setup:
def ethereumSubscribe = new EthereumSubscribe(TestingCommons.emptyMultistream() as EthereumMultistream)
when:
def act = ethereumSubscribe.readLogsRequest([
topics: "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
])
then:
act.address == []
act.topics == [
Hex32.from("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef")
]
when:
act = ethereumSubscribe.readLogsRequest([
topics: ["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"]
])
then:
act.address == []
act.topics == [
Hex32.from("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef")
]
}
def "read invalid topic for request"() {
setup:
def ethereumSubscribe = new EthereumSubscribe(TestingCommons.emptyMultistream() as EthereumMultistream)
when:
def act = ethereumSubscribe.readLogsRequest([
topics: [
"0x401d083b33d092293333a83829bd824b016326a0",
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
]
])
then:
act.address == []
act.topics == [
Hex32.from("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef")
]
}
def "read multi topic logs request"() {
setup:
def ethereumSubscribe = new EthereumSubscribe(TestingCommons.emptyMultistream() as EthereumMultistream)
when:
def act = ethereumSubscribe.readLogsRequest([
topics: [
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
"0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925"
]
])
then:
act.address == []
act.topics == [
Hex32.from("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"),
Hex32.from("0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925")
]
}
def "read full logs request"() {
setup:
def ethereumSubscribe = new EthereumSubscribe(TestingCommons.emptyMultistream() as EthereumMultistream)
when:
def act = ethereumSubscribe.readLogsRequest([
address: "0x298d492e8c1d909d3f63bc4a36c66c64acb3d695",
topics : [
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
"0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925"
]
])
then:
act.address == [
Address.from("0x298d492e8c1d909d3f63bc4a36c66c64acb3d695")
]
act.topics == [
Hex32.from("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"),
Hex32.from("0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925")
]
}
}

View File

@@ -0,0 +1,237 @@
/**
* 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.subscribe
import io.emeraldpay.dshackle.data.BlockContainer
import io.emeraldpay.dshackle.data.BlockId
import io.emeraldpay.dshackle.data.TxId
import io.emeraldpay.dshackle.upstream.Head
import io.emeraldpay.dshackle.upstream.ethereum.EthereumMultistream
import io.emeraldpay.etherjar.domain.BlockHash
import io.emeraldpay.etherjar.domain.TransactionId
import io.emeraldpay.etherjar.hex.Hex32
import io.emeraldpay.etherjar.rpc.json.BlockJson
import io.emeraldpay.etherjar.rpc.json.TransactionRefJson
import reactor.core.publisher.Flux
import spock.lang.Specification
import java.time.Duration
import java.time.Instant
class ConnectBlockUpdatesSpec extends Specification {
def "Extracts updates"() {
setup:
def connectBlockUpdates = new ConnectBlockUpdates(Stub(EthereumMultistream))
def block = BlockContainer.from(new BlockJson<TransactionRefJson>().tap {
hash = BlockHash.from("0xe5be2159b2b7daf6b126babdcbaa349da668b92d6b8c7db1350fd527fec4885c")
number = 13412871
totalDifficulty = BigInteger.ONE
timestamp = Instant.now()
transactions = [
new TransactionRefJson(TransactionId.from("0x6c88df9d65ccc9351db65676c3581b29483e8dabb71c48ef7671c44b0d5568af")),
new TransactionRefJson(TransactionId.from("0x5c241a64e7ce536fdb6b8912091151f18a23dd71cc76a48a4b7d453e339efbe2"))
]
})
when:
def act = connectBlockUpdates.extractUpdates(block)
.collectList().block(Duration.ofSeconds(3))
then:
act.size() == 2
with(act[0]) {
it.blockNumber == 13412871
it.blockHash == BlockId.from("0xe5be2159b2b7daf6b126babdcbaa349da668b92d6b8c7db1350fd527fec4885c")
it.transactionId == TxId.from("0x6c88df9d65ccc9351db65676c3581b29483e8dabb71c48ef7671c44b0d5568af")
it.type == ConnectBlockUpdates.UpdateType.NEW
}
with(act[1]) {
it.blockNumber == 13412871
it.blockHash == BlockId.from("0xe5be2159b2b7daf6b126babdcbaa349da668b92d6b8c7db1350fd527fec4885c")
it.transactionId == TxId.from("0x5c241a64e7ce536fdb6b8912091151f18a23dd71cc76a48a4b7d453e339efbe2")
it.type == ConnectBlockUpdates.UpdateType.NEW
}
}
def "Produce DROP updates for replaced block"() {
setup:
def connectBlockUpdates = new ConnectBlockUpdates(Stub(EthereumMultistream))
def block = BlockContainer.from(new BlockJson<TransactionRefJson>().tap {
hash = BlockHash.from("0xe5be2159b2b7daf6b126babdcbaa349da668b92d6b8c7db1350fd527fec4885c")
number = 13412871
totalDifficulty = BigInteger.ONE
timestamp = Instant.now()
transactions = [
new TransactionRefJson(TransactionId.from("0x6c88df9d65ccc9351db65676c3581b29483e8dabb71c48ef7671c44b0d5568af")),
new TransactionRefJson(TransactionId.from("0x5c241a64e7ce536fdb6b8912091151f18a23dd71cc76a48a4b7d453e339efbe2"))
]
})
when:
def act = connectBlockUpdates.whenReplaced(block)
.collectList().block(Duration.ofSeconds(3))
then:
act.size() == 2
with(act[0]) {
it.blockNumber == 13412871
it.blockHash == BlockId.from("0xe5be2159b2b7daf6b126babdcbaa349da668b92d6b8c7db1350fd527fec4885c")
it.transactionId == TxId.from("0x6c88df9d65ccc9351db65676c3581b29483e8dabb71c48ef7671c44b0d5568af")
it.type == ConnectBlockUpdates.UpdateType.DROP
}
with(act[1]) {
it.blockNumber == 13412871
it.blockHash == BlockId.from("0xe5be2159b2b7daf6b126babdcbaa349da668b92d6b8c7db1350fd527fec4885c")
it.transactionId == TxId.from("0x5c241a64e7ce536fdb6b8912091151f18a23dd71cc76a48a4b7d453e339efbe2")
it.type == ConnectBlockUpdates.UpdateType.DROP
}
}
def "Gets prev version if available"() {
setup:
def connectBlockUpdates = new ConnectBlockUpdates(Stub(EthereumMultistream))
def block1 = BlockContainer.from(new BlockJson<TransactionRefJson>().tap {
hash = BlockHash.from("0xe5be2159b2b7daf6b126babdcbaa349da668b92d6b8c7db1350fd527fec4885c")
number = 13412871
totalDifficulty = BigInteger.ONE
timestamp = Instant.now()
transactions = [
new TransactionRefJson(TransactionId.from("0x6c88df9d65ccc9351db65676c3581b29483e8dabb71c48ef7671c44b0d5568af")),
new TransactionRefJson(TransactionId.from("0x5c241a64e7ce536fdb6b8912091151f18a23dd71cc76a48a4b7d453e339efbe2"))
]
})
def block2 = BlockContainer.from(new BlockJson<TransactionRefJson>().tap {
hash = BlockHash.from("0x668b92d6b8c7db1350fd527fec4885ce5be2159b2b7daf6b126babdcbaa349da")
number = 13412871
totalDifficulty = BigInteger.ONE
timestamp = Instant.now()
transactions = [
new TransactionRefJson(TransactionId.from("0x6c88df9d65ccc9351db65676c3581b29483e8dabb71c48ef7671c44b0d5568af")),
new TransactionRefJson(TransactionId.from("0x5c241a64e7ce536fdb6b8912091151f18a23dd71cc76a48a4b7d453e339efbe2"))
]
})
def block3 = BlockContainer.from(new BlockJson<TransactionRefJson>().tap {
hash = BlockHash.from("0xdb1350fd527fec4885ce5be2159b2b7daf6b126babdcbaa349da668b92d6b8c7")
number = 13412872
totalDifficulty = BigInteger.ONE
timestamp = Instant.now()
transactions = [
new TransactionRefJson(TransactionId.from("0x9351db65676c3581b29483e8dabb71c48ef7671c44b0d5568af6c88df9d65ccc")),
new TransactionRefJson(TransactionId.from("0xdb6b8912091151f18a23dd71cc76a48a4b7d453e339efbe25c241a64e7ce536f"))
]
})
when:
def prev = connectBlockUpdates.findPrevious(block1)
then:
prev == null
when:
prev = connectBlockUpdates.findPrevious(block2)
then:
prev == null
when:
prev = connectBlockUpdates.findPrevious(block3)
then:
prev == null
when:
connectBlockUpdates.remember(block1)
prev = connectBlockUpdates.findPrevious(block2)
then:
prev == block1
when:
prev = connectBlockUpdates.findPrevious(block3)
then:
prev == null
}
def "Marks old txes as dropped before producing a new version of same block"() {
setup:
def connectBlockUpdates = new ConnectBlockUpdates(Stub(EthereumMultistream))
def block1 = BlockContainer.from(new BlockJson<TransactionRefJson>().tap {
hash = BlockHash.from("0xe5be2159b2b7daf6b126babdcbaa349da668b92d6b8c7db1350fd527fec4885c")
number = 13412871
totalDifficulty = BigInteger.ONE
timestamp = Instant.now()
transactions = [
new TransactionRefJson(TransactionId.from("0x6c88df9d65ccc9351db65676c3581b29483e8dabb71c48ef7671c44b0d5568af")),
new TransactionRefJson(TransactionId.from("0x5c241a64e7ce536fdb6b8912091151f18a23dd71cc76a48a4b7d453e339efbe2"))
]
})
def block2 = BlockContainer.from(new BlockJson<TransactionRefJson>().tap {
hash = BlockHash.from("0x668b92d6b8c7db1350fd527fec4885ce5be2159b2b7daf6b126babdcbaa349da")
number = 13412871
totalDifficulty = BigInteger.ONE
timestamp = Instant.now()
transactions = [
new TransactionRefJson(TransactionId.from("0x6c88df9d65ccc9351db65676c3581b29483e8dabb71c48ef7671c44b0d5568af")),
new TransactionRefJson(TransactionId.from("0x5c241a64e7ce536fdb6b8912091151f18a23dd71cc76a48a4b7d453e339efbe2"))
]
})
when:
connectBlockUpdates.remember(block1)
def act = connectBlockUpdates.extract(block2)
.collectList().block(Duration.ofSeconds(1))
then:
act.size() == 4
with(act[0]) {
it.blockNumber == 13412871
it.blockHash == BlockId.from("0xe5be2159b2b7daf6b126babdcbaa349da668b92d6b8c7db1350fd527fec4885c")
it.transactionId == TxId.from("0x6c88df9d65ccc9351db65676c3581b29483e8dabb71c48ef7671c44b0d5568af")
it.type == ConnectBlockUpdates.UpdateType.DROP
}
with(act[1]) {
it.blockNumber == 13412871
it.blockHash == BlockId.from("0xe5be2159b2b7daf6b126babdcbaa349da668b92d6b8c7db1350fd527fec4885c")
it.transactionId == TxId.from("0x5c241a64e7ce536fdb6b8912091151f18a23dd71cc76a48a4b7d453e339efbe2")
it.type == ConnectBlockUpdates.UpdateType.DROP
}
with(act[2]) {
it.blockNumber == 13412871
it.blockHash == BlockId.from("0x668b92d6b8c7db1350fd527fec4885ce5be2159b2b7daf6b126babdcbaa349da")
it.transactionId == TxId.from("0x6c88df9d65ccc9351db65676c3581b29483e8dabb71c48ef7671c44b0d5568af")
it.type == ConnectBlockUpdates.UpdateType.NEW
}
with(act[3]) {
it.blockNumber == 13412871
it.blockHash == BlockId.from("0x668b92d6b8c7db1350fd527fec4885ce5be2159b2b7daf6b126babdcbaa349da")
it.transactionId == TxId.from("0x5c241a64e7ce536fdb6b8912091151f18a23dd71cc76a48a4b7d453e339efbe2")
it.type == ConnectBlockUpdates.UpdateType.NEW
}
}
def "Keeps connection"() {
setup:
def head = Mock(Head) {
1 * getFlux() >> Flux.never()
}
def up = Mock(EthereumMultistream) {
1 * getHead() >> head
}
def connectBlockUpdates = new ConnectBlockUpdates(up)
when:
def a1 = connectBlockUpdates.connect()
def a2 = connectBlockUpdates.connect()
then:
a1 == a2
}
}

View File

@@ -0,0 +1,151 @@
/**
* 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.subscribe
import io.emeraldpay.dshackle.test.TestingCommons
import io.emeraldpay.dshackle.upstream.ethereum.EthereumMultistream
import io.emeraldpay.dshackle.upstream.ethereum.subscribe.json.LogMessage
import io.emeraldpay.etherjar.domain.Address
import io.emeraldpay.etherjar.domain.BlockHash
import io.emeraldpay.etherjar.domain.TransactionId
import io.emeraldpay.etherjar.hex.Hex32
import io.emeraldpay.etherjar.hex.HexData
import reactor.core.publisher.Flux
import spock.lang.Specification
class ConnectLogsSpec extends Specification {
def log1 = new LogMessage(
Address.from("0x298d492e8c1d909d3f63bc4a36c66c64acb3d695"),
BlockHash.empty(),
100L,
HexData.empty(),
1L,
[
Hex32.from("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef")
],
TransactionId.from("0xb5e554178a94fd993111f2ae64cb708cb0899d7b5182024e70d5c468164a8bec"),
1L,
false
)
def log2 = new LogMessage(
Address.from("0x63bc4a36c66c64acb3d695298d492e8c1d909d3f"),
BlockHash.empty(),
100L,
HexData.empty(),
1L,
[
Hex32.from("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef")
],
TransactionId.from("0xb5e554178a94fd993111f2ae64cb708cb0899d7b5182024e70d5c468164a8bec"),
1L,
false
)
def log3 = new LogMessage(
Address.from("0x63bc4a36c66c64acb3d695298d492e8c1d909d3f"),
BlockHash.empty(),
100L,
HexData.empty(),
1L,
[
Hex32.from("0x952ba7f163c4a11628f55a4df523b3efddf252ad1be2c89b69c2b068fc378daa")
],
TransactionId.from("0xb5e554178a94fd993111f2ae64cb708cb0899d7b5182024e70d5c468164a8bec"),
1L,
false
)
def log4 = new LogMessage(
Address.from("0x4a36c66c64acb3d695298d492e8c1d909d3f63bc"),
BlockHash.empty(),
100L,
HexData.empty(),
1L,
[
Hex32.from("0x952ba7f163c4a11628f55a4df523b3efddf252ad1be2c89b69c2b068fc378daa")
],
TransactionId.from("0xb5e554178a94fd993111f2ae64cb708cb0899d7b5182024e70d5c468164a8bec"),
1L,
false
)
def "Filter is empty"() {
setup:
def connectLogs = new ConnectLogs(TestingCommons.emptyMultistream() as EthereumMultistream)
when:
def input = Flux.fromIterable([
log1, log2, log3, log4
])
def act = input.transform(connectLogs.filtered([], []))
.collectList().block()
then:
act.size() == 4
act[0] == log1
act[1] == log2
act[2] == log3
act[3] == log4
}
def "Filter by address"() {
setup:
def connectLogs = new ConnectLogs(TestingCommons.emptyMultistream() as EthereumMultistream)
when:
def input = Flux.fromIterable([
log1, log2
])
def act = input.transform(connectLogs.filtered([Address.from("0x298d492e8c1d909d3f63bc4a36c66c64acb3d695")], []))
.collectList().block()
then:
act.size() == 1
act[0] == log1
}
def "Filter by topic"() {
setup:
def connectLogs = new ConnectLogs(TestingCommons.emptyMultistream() as EthereumMultistream)
when:
def input = Flux.fromIterable([
log1, log2, log3, log4
])
def act = input.transform(connectLogs.filtered([], [Hex32.from("0x952ba7f163c4a11628f55a4df523b3efddf252ad1be2c89b69c2b068fc378daa")]))
.collectList().block()
then:
act.size() == 2
act[0] == log3
act[1] == log4
}
def "Filter by address and topic"() {
setup:
def connectLogs = new ConnectLogs(TestingCommons.emptyMultistream() as EthereumMultistream)
when:
def input = Flux.fromIterable([
log1, log2, log3, log4
])
def act = input.transform(connectLogs.filtered([Address.from("0x63bc4a36c66c64acb3d695298d492e8c1d909d3f")], [Hex32.from("0x952ba7f163c4a11628f55a4df523b3efddf252ad1be2c89b69c2b068fc378daa")]))
.collectList().block()
then:
act.size() == 1
act[0] == log3
}
}

View File

@@ -0,0 +1,34 @@
package io.emeraldpay.dshackle.upstream.ethereum.subscribe
import io.emeraldpay.dshackle.test.TestingCommons
import io.emeraldpay.dshackle.upstream.Head
import io.emeraldpay.dshackle.upstream.ethereum.EthereumMultistream
import reactor.core.publisher.Flux
import reactor.test.StepVerifier
import spock.lang.Specification
class ConnectNewHeadsSpec extends Specification {
def "Reuse same head"() {
setup:
def head = Mock(Head) {
1 * getFlux() >> Flux.fromIterable([
TestingCommons.blockForEthereum(100)
])
}
def up = Mock(EthereumMultistream) {
1 * getHead() >> head
}
ConnectNewHeads connectNewHeads = new ConnectNewHeads(up)
when:
def act1 = connectNewHeads.connect()
def act2 = connectNewHeads.connect()
then:
StepVerifier.create(act1)
.expectNextCount(1)
.expectComplete()
StepVerifier.create(act2)
.expectNextCount(1)
.expectComplete()
}
}

View File

@@ -0,0 +1,347 @@
/**
* 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.subscribe
import io.emeraldpay.dshackle.data.BlockId
import io.emeraldpay.dshackle.data.TxId
import io.emeraldpay.dshackle.reader.Reader
import reactor.core.publisher.Mono
import spock.lang.Specification
import java.time.Duration
class ProduceLogsSpec extends Specification {
def "Produce added as nothing with no logs"() {
setup:
String receipt = '{\n' +
' "blockHash": "0x668b92d6b8c7db1350fd527fec4885ce5be2159b2b7daf6b126babdcbaa349da",\n' +
' "blockNumber": "0x1",\n' +
' "from": "0x5e78dd1e81ecdf078e029117eca98eaa71f46bdb",\n' +
' "logs": [\n' +
' ],\n' +
' "transactionHash": "0x6c88df9d65ccc9351db65676c3581b29483e8dabb71c48ef7671c44b0d5568af",\n' +
' "transactionIndex": "0x0"\n' +
' }'
def receipts = Mock(Reader) {
1 * it.read(TxId.from("0x6c88df9d65ccc9351db65676c3581b29483e8dabb71c48ef7671c44b0d5568af")) >> Mono.just(receipt.getBytes())
}
def producer = new ProduceLogs(receipts)
def update = new ConnectBlockUpdates.Update(
BlockId.from("0x668b92d6b8c7db1350fd527fec4885ce5be2159b2b7daf6b126babdcbaa349da"),
13412871,
ConnectBlockUpdates.UpdateType.NEW,
TxId.from("0x6c88df9d65ccc9351db65676c3581b29483e8dabb71c48ef7671c44b0d5568af")
)
when:
def act = producer.produceAdded(update)
.collectList().block(Duration.ofSeconds(1))
then:
act.size() == 0
}
def "Produce added with single log"() {
setup:
String receipt = '{\n' +
' "blockHash": "0x668b92d6b8c7db1350fd527fec4885ce5be2159b2b7daf6b126babdcbaa349da",\n' +
' "blockNumber": "0x1",\n' +
' "from": "0x5e78dd1e81ecdf078e029117eca98eaa71f46bdb",\n' +
' "logs": [\n' +
' {\n' +
' "address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",\n' +
' "topics": [\n' +
' "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",\n' +
' "0x0000000000000000000000005e78dd1e81ecdf078e029117eca98eaa71f46bdb",\n' +
' "0x00000000000000000000000099897cb0e667d354b920fc38a40a5100b2a01566"\n' +
' ],\n' +
' "data": "0x00000000000000000000000000000000000000000000000000000007505d91f0",\n' +
' "blockNumber": "0xc7f3b4",\n' +
' "transactionHash": "0x6c88df9d65ccc9351db65676c3581b29483e8dabb71c48ef7671c44b0d5568af",\n' +
' "transactionIndex": "0x0",\n' +
' "blockHash": "0x668b92d6b8c7db1350fd527fec4885ce5be2159b2b7daf6b126babdcbaa349da",\n' +
' "logIndex": "0x0",\n' +
' "removed": false\n' +
' }' +
' ],\n' +
' "transactionHash": "0x6c88df9d65ccc9351db65676c3581b29483e8dabb71c48ef7671c44b0d5568af",\n' +
' "transactionIndex": "0x0"\n' +
' }'
def receipts = Mock(Reader) {
1 * it.read(TxId.from("0x6c88df9d65ccc9351db65676c3581b29483e8dabb71c48ef7671c44b0d5568af")) >> Mono.just(receipt.getBytes())
}
def producer = new ProduceLogs(receipts)
def update = new ConnectBlockUpdates.Update(
BlockId.from("0x668b92d6b8c7db1350fd527fec4885ce5be2159b2b7daf6b126babdcbaa349da"),
13412871,
ConnectBlockUpdates.UpdateType.NEW,
TxId.from("0x6c88df9d65ccc9351db65676c3581b29483e8dabb71c48ef7671c44b0d5568af")
)
when:
def act = producer.produceAdded(update)
.collectList().block(Duration.ofSeconds(1))
then:
act.size() == 1
with(act[0]) {
it.transactionHash.toHex() == "0x6c88df9d65ccc9351db65676c3581b29483e8dabb71c48ef7671c44b0d5568af"
}
}
def "Produce added with no data"() {
setup:
String receipt = '{\n' +
' "blockHash": "0x668b92d6b8c7db1350fd527fec4885ce5be2159b2b7daf6b126babdcbaa349da",\n' +
' "blockNumber": "0x1",\n' +
' "from": "0x5e78dd1e81ecdf078e029117eca98eaa71f46bdb",\n' +
' "logs": [\n' +
' {\n' +
' "address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",\n' +
' "topics": [\n' +
' "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"\n' +
' ],\n' +
' "blockNumber": "0xc7f3b4",\n' +
' "transactionHash": "0x6c88df9d65ccc9351db65676c3581b29483e8dabb71c48ef7671c44b0d5568af",\n' +
' "transactionIndex": "0x0",\n' +
' "blockHash": "0x668b92d6b8c7db1350fd527fec4885ce5be2159b2b7daf6b126babdcbaa349da",\n' +
' "logIndex": "0x0",\n' +
' "removed": false\n' +
' }' +
' ],\n' +
' "transactionHash": "0x6c88df9d65ccc9351db65676c3581b29483e8dabb71c48ef7671c44b0d5568af",\n' +
' "transactionIndex": "0x0"\n' +
' }'
def receipts = Mock(Reader) {
1 * it.read(TxId.from("0x6c88df9d65ccc9351db65676c3581b29483e8dabb71c48ef7671c44b0d5568af")) >> Mono.just(receipt.getBytes())
}
def producer = new ProduceLogs(receipts)
def update = new ConnectBlockUpdates.Update(
BlockId.from("0x668b92d6b8c7db1350fd527fec4885ce5be2159b2b7daf6b126babdcbaa349da"),
13412871,
ConnectBlockUpdates.UpdateType.NEW,
TxId.from("0x6c88df9d65ccc9351db65676c3581b29483e8dabb71c48ef7671c44b0d5568af")
)
when:
def act = producer.produceAdded(update)
.collectList().block(Duration.ofSeconds(1))
then:
act.size() == 1
with(act[0]) {
it.transactionHash.toHex() == "0x6c88df9d65ccc9351db65676c3581b29483e8dabb71c48ef7671c44b0d5568af"
// Geth actually renders it as null, so this check may be wrong
it.data != null && it.data.size == 0
}
}
def "Produce added with multiple logs"() {
setup:
String receipt = '{\n' +
' "blockHash": "0x668b92d6b8c7db1350fd527fec4885ce5be2159b2b7daf6b126babdcbaa349da",\n' +
' "blockNumber": "0x1",\n' +
' "from": "0x5e78dd1e81ecdf078e029117eca98eaa71f46bdb",\n' +
' "logs": [\n' +
' {\n' +
' "address": "0x298d492e8c1d909d3f63bc4a36c66c64acb3d695",\n' +
' "topics": [\n' +
' "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",\n' +
' "0x0000000000000000000000005c6006105b1b777a13d58d96393ad9e556882025",\n' +
' "0x00000000000000000000000075b8c48bdb04d426aed57b36bb835ad2dc321c30"\n' +
' ],\n' +
' "data": "0x0000000000000000000000000000000000000000000000013e7ec767db370000",\n' +
' "blockNumber": "0xccc493",\n' +
' "transactionHash": "0xb5e554178a94fd993111f2ae64cb708cb0899d7b5182024e70d5c468164a8bec",\n' +
' "transactionIndex": "0x57",\n' +
' "blockHash": "0x7e2661ac0e2f34dd2d6f449eea45aeec8470a0948af9daa33e684226640d819c",\n' +
' "logIndex": "0xb4",\n' +
' "removed": false\n' +
' },\n' +
' {\n' +
' "address": "0x298d492e8c1d909d3f63bc4a36c66c64acb3d695",\n' +
' "topics": [\n' +
' "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",\n' +
' "0x0000000000000000000000005c6006105b1b777a13d58d96393ad9e556882025",\n' +
' "0x0000000000000000000000000000000000000000000000000000000000000000"\n' +
' ],\n' +
' "data": "0x00000000000000000000000000000000000000000000000023636b7d513f0000",\n' +
' "blockNumber": "0xccc493",\n' +
' "transactionHash": "0xb5e554178a94fd993111f2ae64cb708cb0899d7b5182024e70d5c468164a8bec",\n' +
' "transactionIndex": "0x57",\n' +
' "blockHash": "0x7e2661ac0e2f34dd2d6f449eea45aeec8470a0948af9daa33e684226640d819c",\n' +
' "logIndex": "0xb5",\n' +
' "removed": false\n' +
' },\n' +
' {\n' +
' "address": "0x298d492e8c1d909d3f63bc4a36c66c64acb3d695",\n' +
' "topics": [\n' +
' "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",\n' +
' "0x0000000000000000000000005c6006105b1b777a13d58d96393ad9e556882025",\n' +
' "0x0000000000000000000000001b46b72c5280f30fbe8a958b4f3c348fd0fd2e55"\n' +
' ],\n' +
' "data": "0x00000000000000000000000000000000000000000000011316d590258fba0000",\n' +
' "blockNumber": "0xccc493",\n' +
' "transactionHash": "0xb5e554178a94fd993111f2ae64cb708cb0899d7b5182024e70d5c468164a8bec",\n' +
' "transactionIndex": "0x57",\n' +
' "blockHash": "0x7e2661ac0e2f34dd2d6f449eea45aeec8470a0948af9daa33e684226640d819c",\n' +
' "logIndex": "0xb6",\n' +
' "removed": false\n' +
' },\n' +
' {\n' +
' "address": "0x298d492e8c1d909d3f63bc4a36c66c64acb3d695",\n' +
' "topics": [\n' +
' "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925",\n' +
' "0x0000000000000000000000005c6006105b1b777a13d58d96393ad9e556882025",\n' +
' "0x0000000000000000000000001b46b72c5280f30fbe8a958b4f3c348fd0fd2e55"\n' +
' ],\n' +
' "data": "0x0000000000000000000000000000000000000000000014188a101e403a500000",\n' +
' "blockNumber": "0xccc493",\n' +
' "transactionHash": "0xb5e554178a94fd993111f2ae64cb708cb0899d7b5182024e70d5c468164a8bec",\n' +
' "transactionIndex": "0x57",\n' +
' "blockHash": "0x7e2661ac0e2f34dd2d6f449eea45aeec8470a0948af9daa33e684226640d819c",\n' +
' "logIndex": "0xb7",\n' +
' "removed": false\n' +
' },\n' +
' {\n' +
' "address": "0x1b46b72c5280f30fbe8a958b4f3c348fd0fd2e55",\n' +
' "topics": [\n' +
' "0x90890809c654f11d6e72a28fa60149770a0d11ec6c92319d6ceb2bb0a4ea1a15",\n' +
' "0x0000000000000000000000005c6006105b1b777a13d58d96393ad9e556882025",\n' +
' "0x0000000000000000000000000000000000000000000000000000000000000000"\n' +
' ],\n' +
' "data": "0x00000000000000000000000000000000000000000000011478b7c30abc300000",\n' +
' "blockNumber": "0xccc493",\n' +
' "transactionHash": "0xb5e554178a94fd993111f2ae64cb708cb0899d7b5182024e70d5c468164a8bec",\n' +
' "transactionIndex": "0x57",\n' +
' "blockHash": "0x7e2661ac0e2f34dd2d6f449eea45aeec8470a0948af9daa33e684226640d819c",\n' +
' "logIndex": "0xb8",\n' +
' "removed": false\n' +
' }' +
' ],\n' +
' "transactionHash": "0xb5e554178a94fd993111f2ae64cb708cb0899d7b5182024e70d5c468164a8bec",\n' +
' "transactionIndex": "0x57"\n' +
' }'
def receipts = Mock(Reader) {
1 * it.read(TxId.from("0xb5e554178a94fd993111f2ae64cb708cb0899d7b5182024e70d5c468164a8bec")) >> Mono.just(receipt.getBytes())
}
def producer = new ProduceLogs(receipts)
def update = new ConnectBlockUpdates.Update(
BlockId.from("0x668b92d6b8c7db1350fd527fec4885ce5be2159b2b7daf6b126babdcbaa349da"),
13412871,
ConnectBlockUpdates.UpdateType.NEW,
TxId.from("0xb5e554178a94fd993111f2ae64cb708cb0899d7b5182024e70d5c468164a8bec")
)
when:
def act = producer.produceAdded(update)
.collectList().block(Duration.ofSeconds(1))
then:
act.size() == 5
act*.transactionHash.every { it.toHex() == "0xb5e554178a94fd993111f2ae64cb708cb0899d7b5182024e70d5c468164a8bec" }
act*.logIndex == [180, 181, 182, 183, 184]
act*.removed.every { !it }
}
def "Produce removed"() {
setup:
String receipt = '{\n' +
' "blockHash": "0x668b92d6b8c7db1350fd527fec4885ce5be2159b2b7daf6b126babdcbaa349da",\n' +
' "blockNumber": "0x1",\n' +
' "from": "0x5e78dd1e81ecdf078e029117eca98eaa71f46bdb",\n' +
' "logs": [\n' +
' {\n' +
' "address": "0x298d492e8c1d909d3f63bc4a36c66c64acb3d695",\n' +
' "topics": [\n' +
' "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",\n' +
' "0x0000000000000000000000005c6006105b1b777a13d58d96393ad9e556882025",\n' +
' "0x00000000000000000000000075b8c48bdb04d426aed57b36bb835ad2dc321c30"\n' +
' ],\n' +
' "data": "0x0000000000000000000000000000000000000000000000013e7ec767db370000",\n' +
' "blockNumber": "0xccc493",\n' +
' "transactionHash": "0xb5e554178a94fd993111f2ae64cb708cb0899d7b5182024e70d5c468164a8bec",\n' +
' "transactionIndex": "0x57",\n' +
' "blockHash": "0x7e2661ac0e2f34dd2d6f449eea45aeec8470a0948af9daa33e684226640d819c",\n' +
' "logIndex": "0xb4",\n' +
' "removed": false\n' +
' },\n' +
' {\n' +
' "address": "0x298d492e8c1d909d3f63bc4a36c66c64acb3d695",\n' +
' "topics": [\n' +
' "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",\n' +
' "0x0000000000000000000000005c6006105b1b777a13d58d96393ad9e556882025",\n' +
' "0x0000000000000000000000000000000000000000000000000000000000000000"\n' +
' ],\n' +
' "data": "0x00000000000000000000000000000000000000000000000023636b7d513f0000",\n' +
' "blockNumber": "0xccc493",\n' +
' "transactionHash": "0xb5e554178a94fd993111f2ae64cb708cb0899d7b5182024e70d5c468164a8bec",\n' +
' "transactionIndex": "0x57",\n' +
' "blockHash": "0x7e2661ac0e2f34dd2d6f449eea45aeec8470a0948af9daa33e684226640d819c",\n' +
' "logIndex": "0xb5",\n' +
' "removed": false\n' +
' },\n' +
' {\n' +
' "address": "0x298d492e8c1d909d3f63bc4a36c66c64acb3d695",\n' +
' "topics": [\n' +
' "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",\n' +
' "0x0000000000000000000000005c6006105b1b777a13d58d96393ad9e556882025",\n' +
' "0x0000000000000000000000001b46b72c5280f30fbe8a958b4f3c348fd0fd2e55"\n' +
' ],\n' +
' "data": "0x00000000000000000000000000000000000000000000011316d590258fba0000",\n' +
' "blockNumber": "0xccc493",\n' +
' "transactionHash": "0xb5e554178a94fd993111f2ae64cb708cb0899d7b5182024e70d5c468164a8bec",\n' +
' "transactionIndex": "0x57",\n' +
' "blockHash": "0x7e2661ac0e2f34dd2d6f449eea45aeec8470a0948af9daa33e684226640d819c",\n' +
' "logIndex": "0xb6",\n' +
' "removed": false\n' +
' }\n' +
' ],\n' +
' "transactionHash": "0xb5e554178a94fd993111f2ae64cb708cb0899d7b5182024e70d5c468164a8bec",\n' +
' "transactionIndex": "0x57"\n' +
' }'
def receipts = Mock(Reader) {
1 * it.read(TxId.from("0xb5e554178a94fd993111f2ae64cb708cb0899d7b5182024e70d5c468164a8bec")) >> Mono.just(receipt.getBytes())
}
def producer = new ProduceLogs(receipts)
def update1 = new ConnectBlockUpdates.Update(
BlockId.from("0x668b92d6b8c7db1350fd527fec4885ce5be2159b2b7daf6b126babdcbaa349da"),
13412871,
ConnectBlockUpdates.UpdateType.NEW,
TxId.from("0xb5e554178a94fd993111f2ae64cb708cb0899d7b5182024e70d5c468164a8bec")
)
def update2 = new ConnectBlockUpdates.Update(
BlockId.from("0x668b92d6b8c7db1350fd527fec4885ce5be2159b2b7daf6b126babdcbaa349da"),
13412871,
ConnectBlockUpdates.UpdateType.DROP,
TxId.from("0xb5e554178a94fd993111f2ae64cb708cb0899d7b5182024e70d5c468164a8bec")
)
when:
// first need to produce them as added, because that's when it remembers logs to "remove"
producer.produceAdded(update1)
.collectList().block(Duration.ofSeconds(1))
def act = producer.produceRemoved(update2)
.collectList().block(Duration.ofSeconds(1))
then:
act.size() == 3
act*.transactionHash.every { it.toHex() == "0xb5e554178a94fd993111f2ae64cb708cb0899d7b5182024e70d5c468164a8bec" }
act*.logIndex == [180, 181, 182]
act*.removed.every { it }
}
}

View File

@@ -0,0 +1,67 @@
/**
* 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.subscribe.json
import com.fasterxml.jackson.databind.ObjectMapper
import io.emeraldpay.dshackle.Global
import io.emeraldpay.etherjar.domain.Address
import io.emeraldpay.etherjar.domain.BlockHash
import io.emeraldpay.etherjar.domain.TransactionId
import io.emeraldpay.etherjar.hex.Hex32
import io.emeraldpay.etherjar.hex.HexData
import spock.lang.Specification
class LogMessageSpec extends Specification {
def "Serialize to a correct JSON"() {
setup:
def msg = new LogMessage(
Address.from("0x011b6e24ffb0b5f5fcc564cf4183c5bbbc96d515"),
BlockHash.from("0x48249c81bfced2e6fe2536126471b73d83c4f21de75f88a16feb57cc566b991b"),
0xccf6e2,
HexData.from("0x0000000000000000000000004dbd4fc535ac27206064b68ffcf827b0a60bab3f0000000000000000000000000000000000000000000000000000000000000009000000000000000000000000290328354c99b119d891a30d326bad92e36e78596782b7e23208a269f0b8262da05a2e22f4befd147ca89a47991d04b541087789"),
0xe7,
[
Hex32.from("0x23be8e12e420b5da9fb98d8102572f640fb3c11a0085060472dfc0ed194b3cf7"),
Hex32.from("0x000000000000000000000000000000000000000000000000000000000002bcff"),
Hex32.from("0xd3847bbd7bdf7bf84c0a165d198f956f7ccffebdaf1413b5a4a77980d8b6a890")
],
TransactionId.from("0xc7529e79f78f58125abafeaea01fe3abdc6f45c173d5dfb36716cbc526e5b2d1"),
0xa3,
false)
ObjectMapper objectMapper = Global.getObjectMapper()
def exp = '{' +
'"address":"0x011b6e24ffb0b5f5fcc564cf4183c5bbbc96d515",' +
'"blockHash":"0x48249c81bfced2e6fe2536126471b73d83c4f21de75f88a16feb57cc566b991b",' +
'"blockNumber":"0xccf6e2",' +
'"data":"0x0000000000000000000000004dbd4fc535ac27206064b68ffcf827b0a60bab3f0000000000000000000000000000000000000000000000000000000000000009000000000000000000000000290328354c99b119d891a30d326bad92e36e78596782b7e23208a269f0b8262da05a2e22f4befd147ca89a47991d04b541087789",' +
'"logIndex":"0xe7",' +
'"topics":[' +
'"0x23be8e12e420b5da9fb98d8102572f640fb3c11a0085060472dfc0ed194b3cf7",' +
'"0x000000000000000000000000000000000000000000000000000000000002bcff",' +
'"0xd3847bbd7bdf7bf84c0a165d198f956f7ccffebdaf1413b5a4a77980d8b6a890"' +
'],' +
'"transactionHash":"0xc7529e79f78f58125abafeaea01fe3abdc6f45c173d5dfb36716cbc526e5b2d1",' +
'"transactionIndex":"0xa3",' +
'"removed":false' +
'}'
when:
def json = objectMapper.writeValueAsString(msg)
then:
json == exp
}
}

View File

@@ -0,0 +1,46 @@
package io.emeraldpay.dshackle.upstream.ethereum.subscribe.json
import com.fasterxml.jackson.databind.ObjectMapper
import io.emeraldpay.dshackle.Global
import io.emeraldpay.etherjar.domain.Address
import io.emeraldpay.etherjar.domain.BlockHash
import io.emeraldpay.etherjar.domain.Bloom
import spock.lang.Specification
import java.time.Instant
class NewHeadMessageSpec extends Specification {
def "Serialize to a correct JSON"() {
setup:
NewHeadMessage obj = new NewHeadMessage(
0xc7f3b4,
BlockHash.from("0xd3b7ae1a79f5418debae9b8e9318094298c087183be0f7a0151b0e76ba38d6bc"),
BlockHash.from("0xcda7fd1d6ee2d5da7505a0634e27f41d5ae87a344cd75bb64c1dc0863fbe9c0a"),
Instant.ofEpochSecond(0x6128264d),
new BigInteger("1de7f7a458cc08", 16),
0x1ca35ef,
0x7bb33e,
Bloom.from("0x012040020880820356a20b8e980a2004c19f1291800501040001180bb029d0002d8c49440e002048ca00d48581000d900a458100c90139056140880582f22a0a8050224c020c233be8c3080c0a016aa4226a2001446c800822080445a2454118139804001202068401841900840c484222420c4b2022046052c0011e81a9e450085883708545810592e40040010411442300080b0130711f602880600a30c90702cb420a0102a644820650908802840810948142541404884300acc69d000840702020224c000020200880c10858418408098a61445b0ab0480234862655a5000434311b91044849c165040411aa0400b00008222642d24313020d9022219120"),
Address.from("0x829bd824b016326a401d083b33d092293333a830"),
null
)
ObjectMapper objectMapper = Global.getObjectMapper()
def exp = '{' +
'"number":"0xc7f3b4",' +
'"hash":"0xd3b7ae1a79f5418debae9b8e9318094298c087183be0f7a0151b0e76ba38d6bc",' +
'"parentHash":"0xcda7fd1d6ee2d5da7505a0634e27f41d5ae87a344cd75bb64c1dc0863fbe9c0a",' +
'"timestamp":"0x6128264d",' +
'"difficulty":"0x1de7f7a458cc08",' +
'"gasLimit":"0x1ca35ef",' +
'"gasUsed":"0x7bb33e",' +
'"logsBloom":"0x012040020880820356a20b8e980a2004c19f1291800501040001180bb029d0002d8c49440e002048ca00d48581000d900a458100c90139056140880582f22a0a8050224c020c233be8c3080c0a016aa4226a2001446c800822080445a2454118139804001202068401841900840c484222420c4b2022046052c0011e81a9e450085883708545810592e40040010411442300080b0130711f602880600a30c90702cb420a0102a644820650908802840810948142541404884300acc69d000840702020224c000020200880c10858418408098a61445b0ab0480234862655a5000434311b91044849c165040411aa0400b00008222642d24313020d9022219120",' +
'"miner":"0x829bd824b016326a401d083b33d092293333a830"' +
'}'
when:
def json = objectMapper.writeValueAsString(obj)
then:
json == exp
}
}