problem: Bitcoin head check may lag because it checks it only once in 15 seconds
solution: use ZeroMQ connection to listen for new blocks
This commit is contained in:
@@ -44,14 +44,8 @@ Blockchains support:
|
|||||||
|
|
||||||
image::dshackle-intro.png[alt="",width=80%,align="center"]
|
image::dshackle-intro.png[alt="",width=80%,align="center"]
|
||||||
|
|
||||||
== Roadmap
|
|
||||||
|
|
||||||
WARNING: The project is still under development, please use with caution.
|
WARNING: The project is still under development, please use with caution.
|
||||||
|
|
||||||
- [ ] Subscription to bitcoind notification over gRPC (instead of ZeroMQ)
|
|
||||||
- [ ] Lightweight sidecar node connector
|
|
||||||
- [ ] Configurable upstream roles
|
|
||||||
|
|
||||||
== Quick Start
|
== Quick Start
|
||||||
|
|
||||||
=== Configuration
|
=== Configuration
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ dependencies {
|
|||||||
|
|
||||||
implementation libs.bundles.grpc
|
implementation libs.bundles.grpc
|
||||||
implementation libs.bundles.netty
|
implementation libs.bundles.netty
|
||||||
|
implementation libs.zeromq
|
||||||
implementation(libs.bundles.spring.framework) {
|
implementation(libs.bundles.spring.framework) {
|
||||||
exclude module: 'spring-boot-starter-logging'
|
exclude module: 'spring-boot-starter-logging'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,9 +142,12 @@ cluster:
|
|||||||
basic-auth:
|
basic-auth:
|
||||||
username: bitcoin
|
username: bitcoin
|
||||||
password: e984af45bb888428207c290
|
password: e984af45bb888428207c290
|
||||||
# uses Esplora index to fetch balances and utxo for an address
|
# use Esplora index to fetch balances and utxo for an address
|
||||||
esplora:
|
esplora:
|
||||||
url: "http://localhost:3001"
|
url: "http://localhost:3001"
|
||||||
|
# connect via ZeroMQ to get notifications about new blocks
|
||||||
|
zeromq:
|
||||||
|
address: "http://localhost:5555"
|
||||||
- id: remote
|
- id: remote
|
||||||
connection:
|
connection:
|
||||||
grpc:
|
grpc:
|
||||||
@@ -720,7 +723,9 @@ See link:09-quorum-and-selectors.adoc[Quorum and Selectors]
|
|||||||
|
|
||||||
|===
|
|===
|
||||||
|
|
||||||
.Connection Config
|
==== Ethereum Connection Options
|
||||||
|
|
||||||
|
.Connection Config for Ethereum Upstream
|
||||||
[cols="2a,5"]
|
[cols="2a,5"]
|
||||||
|===
|
|===
|
||||||
| Option | Description
|
| Option | Description
|
||||||
@@ -764,6 +769,41 @@ Default is 15Mb
|
|||||||
|
|
||||||
|===
|
|===
|
||||||
|
|
||||||
|
==== Bitcoin Connection Options
|
||||||
|
|
||||||
|
.Connection Config for Bitcoin Upstream
|
||||||
|
[cols="2a,5"]
|
||||||
|
|===
|
||||||
|
| Option | Description
|
||||||
|
|
||||||
|
| `rpc.url`
|
||||||
|
a| HTTP URL to connect to. This is required for a connection. +
|
||||||
|
URL can be configured with Environment Variable placeholders `${ENV_VAR_NAME}`. +
|
||||||
|
Example: `http://${NODE_HOST}:${NODE_PORT}`
|
||||||
|
|
||||||
|
| `rpc.basic-auth` + `rpc.basic-auth.username`, `rpc.basic-auth.password`
|
||||||
|
a| HTTP Basic Auth configuration, which is required by the Bitcoind server. +
|
||||||
|
Values can also reference env variables, for example:
|
||||||
|
[source,yaml]
|
||||||
|
----
|
||||||
|
rpc:
|
||||||
|
url: "http://127.0.0.1:8332"
|
||||||
|
basic-auth:
|
||||||
|
username: "${NODE_USERNAME}"
|
||||||
|
password: "${NODE_PASSWORD}"
|
||||||
|
----
|
||||||
|
|
||||||
|
| `zeromq.address`
|
||||||
|
a| Set up an additional connection via ZeroMQ protocol to subscribe to the new blocks.
|
||||||
|
The node must be launched with the same address specified as `-zmqpubhashblock="tcp://${HOST}:${POST}"` or in `bitcoin.conf`
|
||||||
|
[source,yaml]
|
||||||
|
----
|
||||||
|
zeromq:
|
||||||
|
address: "127.0.0.1:5555"
|
||||||
|
----
|
||||||
|
|
||||||
|
|===
|
||||||
|
|
||||||
[#upstream-dshackle]
|
[#upstream-dshackle]
|
||||||
=== Dshackle Upstream
|
=== Dshackle Upstream
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ netty-buffer = { module = "io.netty:netty-buffer", version.ref = "netty" }
|
|||||||
netty-tcnative-core = { module = "io.netty:netty-tcnative", version.ref = "netty-tcnative" }
|
netty-tcnative-core = { module = "io.netty:netty-tcnative", version.ref = "netty-tcnative" }
|
||||||
netty-tcnative-boringssl = { module = "io.netty:netty-tcnative-boringssl-static", version.ref = "netty-tcnative" }
|
netty-tcnative-boringssl = { module = "io.netty:netty-tcnative-boringssl-static", version.ref = "netty-tcnative" }
|
||||||
|
|
||||||
|
zeromq = "org.zeromq:jeromq:0.5.2"
|
||||||
|
|
||||||
objgenesis = "org.objenesis:objenesis:3.1"
|
objgenesis = "org.objenesis:objenesis:3.1"
|
||||||
|
|
||||||
reactor-core = { module = "io.projectreactor:reactor-core", version.ref = "reactor" }
|
reactor-core = { module = "io.projectreactor:reactor-core", version.ref = "reactor" }
|
||||||
|
|||||||
@@ -111,8 +111,14 @@ open class UpstreamsConfig {
|
|||||||
|
|
||||||
class BitcoinConnection : RpcConnection() {
|
class BitcoinConnection : RpcConnection() {
|
||||||
var esplora: HttpEndpoint? = null
|
var esplora: HttpEndpoint? = null
|
||||||
|
var zeroMq: BitcoinZeroMq? = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class BitcoinZeroMq(
|
||||||
|
val host: String = "127.0.0.1",
|
||||||
|
val port: Int
|
||||||
|
)
|
||||||
|
|
||||||
class HttpEndpoint(val url: URI) {
|
class HttpEndpoint(val url: URI) {
|
||||||
var basicAuth: AuthConfig.ClientBasicAuth? = null
|
var basicAuth: AuthConfig.ClientBasicAuth? = null
|
||||||
var tls: AuthConfig.ClientTlsAuth? = null
|
var tls: AuthConfig.ClientTlsAuth? = null
|
||||||
|
|||||||
@@ -153,6 +153,25 @@ class UpstreamsConfigReader(
|
|||||||
connection.esplora = http
|
connection.esplora = http
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
getMapping(connConfigNode, "zeromq")?.let { node ->
|
||||||
|
getValueAsString(node, "address")?.let { address ->
|
||||||
|
val zmqConfig: Pair<String, Int>? = try {
|
||||||
|
if (address.contains(":")) {
|
||||||
|
address.split(":").let {
|
||||||
|
Pair(it[0], it[1].toInt())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Pair("127.0.0.1", address.toInt())
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
log.warn("Invalid config for ZeroMQ: $address. Expected to be in format HOST:PORT")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
zmqConfig?.let {
|
||||||
|
connection.zeroMq = UpstreamsConfig.BitcoinZeroMq(it.first, it.second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
log.error("Upstream at #0 has invalid configuration")
|
log.error("Upstream at #0 has invalid configuration")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,12 +18,17 @@ package io.emeraldpay.dshackle.startup
|
|||||||
|
|
||||||
import io.emeraldpay.dshackle.FileResolver
|
import io.emeraldpay.dshackle.FileResolver
|
||||||
import io.emeraldpay.dshackle.Global
|
import io.emeraldpay.dshackle.Global
|
||||||
import io.emeraldpay.dshackle.cache.CachesFactory
|
|
||||||
import io.emeraldpay.dshackle.config.UpstreamsConfig
|
import io.emeraldpay.dshackle.config.UpstreamsConfig
|
||||||
import io.emeraldpay.dshackle.reader.Reader
|
import io.emeraldpay.dshackle.reader.Reader
|
||||||
import io.emeraldpay.dshackle.upstream.CurrentMultistreamHolder
|
import io.emeraldpay.dshackle.upstream.CurrentMultistreamHolder
|
||||||
|
import io.emeraldpay.dshackle.upstream.Head
|
||||||
|
import io.emeraldpay.dshackle.upstream.MergedHead
|
||||||
|
import io.emeraldpay.dshackle.upstream.bitcoin.BitcoinRpcHead
|
||||||
import io.emeraldpay.dshackle.upstream.bitcoin.BitcoinRpcUpstream
|
import io.emeraldpay.dshackle.upstream.bitcoin.BitcoinRpcUpstream
|
||||||
|
import io.emeraldpay.dshackle.upstream.bitcoin.BitcoinZMQHead
|
||||||
import io.emeraldpay.dshackle.upstream.bitcoin.EsploraClient
|
import io.emeraldpay.dshackle.upstream.bitcoin.EsploraClient
|
||||||
|
import io.emeraldpay.dshackle.upstream.bitcoin.ExtractBlock
|
||||||
|
import io.emeraldpay.dshackle.upstream.bitcoin.ZMQServer
|
||||||
import io.emeraldpay.dshackle.upstream.calls.CallMethods
|
import io.emeraldpay.dshackle.upstream.calls.CallMethods
|
||||||
import io.emeraldpay.dshackle.upstream.calls.ManagedCallMethods
|
import io.emeraldpay.dshackle.upstream.calls.ManagedCallMethods
|
||||||
import io.emeraldpay.dshackle.upstream.ethereum.EthereumRpcUpstream
|
import io.emeraldpay.dshackle.upstream.ethereum.EthereumRpcUpstream
|
||||||
@@ -52,7 +57,6 @@ open class ConfiguredUpstreams(
|
|||||||
@Autowired private val currentUpstreams: CurrentMultistreamHolder,
|
@Autowired private val currentUpstreams: CurrentMultistreamHolder,
|
||||||
@Autowired private val fileResolver: FileResolver,
|
@Autowired private val fileResolver: FileResolver,
|
||||||
@Autowired private val config: UpstreamsConfig,
|
@Autowired private val config: UpstreamsConfig,
|
||||||
@Autowired private val cachesFactory: CachesFactory
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val log = LoggerFactory.getLogger(ConfiguredUpstreams::class.java)
|
private val log = LoggerFactory.getLogger(ConfiguredUpstreams::class.java)
|
||||||
@@ -159,11 +163,19 @@ open class ConfiguredUpstreams(
|
|||||||
EsploraClient(endpoint.url, endpoint.basicAuth, tls)
|
EsploraClient(endpoint.url, endpoint.basicAuth, tls)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val extractBlock = ExtractBlock()
|
||||||
|
val rpcHead = BitcoinRpcHead(directApi, extractBlock)
|
||||||
|
val head: Head = conn.zeroMq?.let { zeroMq ->
|
||||||
|
val server = ZMQServer(zeroMq.host, zeroMq.port, "hashblock")
|
||||||
|
val zeroMqHead = BitcoinZMQHead(server, directApi, extractBlock)
|
||||||
|
MergedHead(listOf(rpcHead, zeroMqHead))
|
||||||
|
} ?: rpcHead
|
||||||
|
|
||||||
val methods = buildMethods(config, chain)
|
val methods = buildMethods(config, chain)
|
||||||
val upstream = BitcoinRpcUpstream(
|
val upstream = BitcoinRpcUpstream(
|
||||||
config.id
|
config.id
|
||||||
?: "bitcoin-${seq.getAndIncrement()}",
|
?: "bitcoin-${seq.getAndIncrement()}",
|
||||||
chain, directApi,
|
chain, directApi, head,
|
||||||
options, config.role,
|
options, config.role,
|
||||||
QuorumForLabels.QuorumItem(1, config.labels),
|
QuorumForLabels.QuorumItem(1, config.labels),
|
||||||
methods, esplora
|
methods, esplora
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import java.util.Collections
|
|||||||
import java.util.concurrent.Callable
|
import java.util.concurrent.Callable
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.concurrent.locks.ReentrantLock
|
import java.util.concurrent.locks.ReentrantLock
|
||||||
|
import javax.annotation.PreDestroy
|
||||||
import kotlin.concurrent.withLock
|
import kotlin.concurrent.withLock
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
@@ -145,4 +146,15 @@ open class CurrentMultistreamHolder(
|
|||||||
override fun isAvailable(chain: Chain): Boolean {
|
override fun isAvailable(chain: Chain): Boolean {
|
||||||
return chainMapping.containsKey(chain) && callTargets.containsKey(chain)
|
return chainMapping.containsKey(chain) && callTargets.containsKey(chain)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
fun shutdown() {
|
||||||
|
log.info("Closing upstream connections...")
|
||||||
|
updateLock.withLock {
|
||||||
|
chainMapping.values.forEach {
|
||||||
|
it.stop()
|
||||||
|
}
|
||||||
|
chainMapping.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ open class BitcoinRpcUpstream(
|
|||||||
id: String,
|
id: String,
|
||||||
chain: Chain,
|
chain: Chain,
|
||||||
private val directApi: Reader<JsonRpcRequest, JsonRpcResponse>,
|
private val directApi: Reader<JsonRpcRequest, JsonRpcResponse>,
|
||||||
|
private val head: Head,
|
||||||
options: UpstreamsConfig.Options,
|
options: UpstreamsConfig.Options,
|
||||||
role: UpstreamsConfig.UpstreamRole,
|
role: UpstreamsConfig.UpstreamRole,
|
||||||
node: QuorumForLabels.QuorumItem,
|
node: QuorumForLabels.QuorumItem,
|
||||||
@@ -45,7 +46,6 @@ open class BitcoinRpcUpstream(
|
|||||||
private val log = LoggerFactory.getLogger(BitcoinRpcUpstream::class.java)
|
private val log = LoggerFactory.getLogger(BitcoinRpcUpstream::class.java)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val head: Head = createHead()
|
|
||||||
private var validatorSubscription: Disposable? = null
|
private var validatorSubscription: Disposable? = null
|
||||||
|
|
||||||
private val capabilities = if (options.providesBalance == true) {
|
private val capabilities = if (options.providesBalance == true) {
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package io.emeraldpay.dshackle.upstream.bitcoin
|
||||||
|
|
||||||
|
import io.emeraldpay.dshackle.Defaults
|
||||||
|
import io.emeraldpay.dshackle.data.BlockContainer
|
||||||
|
import io.emeraldpay.dshackle.reader.Reader
|
||||||
|
import io.emeraldpay.dshackle.upstream.AbstractHead
|
||||||
|
import io.emeraldpay.dshackle.upstream.Head
|
||||||
|
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcRequest
|
||||||
|
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcResponse
|
||||||
|
import org.apache.commons.codec.binary.Hex
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.context.Lifecycle
|
||||||
|
import reactor.core.Disposable
|
||||||
|
import reactor.core.publisher.Flux
|
||||||
|
import reactor.core.publisher.Mono
|
||||||
|
import reactor.util.retry.Retry
|
||||||
|
import java.time.Duration
|
||||||
|
|
||||||
|
class BitcoinZMQHead(
|
||||||
|
private val server: ZMQServer,
|
||||||
|
private val api: Reader<JsonRpcRequest, JsonRpcResponse>,
|
||||||
|
private val extractBlock: ExtractBlock,
|
||||||
|
) : Head, AbstractHead(), Lifecycle {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val log = LoggerFactory.getLogger(BitcoinZMQHead::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var refreshSubscription: Disposable? = null
|
||||||
|
|
||||||
|
fun connect(): Flux<BlockContainer> {
|
||||||
|
return Flux.from(server.sink.asFlux())
|
||||||
|
.onBackpressureLatest()
|
||||||
|
.map {
|
||||||
|
Hex.encodeHexString(it)
|
||||||
|
}
|
||||||
|
.flatMap { hash ->
|
||||||
|
api.read(JsonRpcRequest("getblock", listOf(hash)))
|
||||||
|
.switchIfEmpty(Mono.error(IllegalStateException("Block $hash is not available on upstream")))
|
||||||
|
.retryWhen(Retry.backoff(5, Duration.ofMillis(100)))
|
||||||
|
.switchIfEmpty(Mono.fromCallable { log.warn("Block $hash is not available on upstream") }.then(Mono.empty()))
|
||||||
|
.flatMap(JsonRpcResponse::requireResult)
|
||||||
|
.map(extractBlock::extract)
|
||||||
|
.timeout(Defaults.timeout, Mono.error(Exception("Block data is not received")))
|
||||||
|
}
|
||||||
|
.onErrorResume { t ->
|
||||||
|
log.warn("Failed to get a block from upstream with error: ${t.message}")
|
||||||
|
connect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun start() {
|
||||||
|
server.start()
|
||||||
|
refreshSubscription = super.follow(connect())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stop() {
|
||||||
|
server.stop()
|
||||||
|
val copy = refreshSubscription
|
||||||
|
refreshSubscription = null
|
||||||
|
copy?.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isRunning(): Boolean {
|
||||||
|
return server.isRunning || refreshSubscription != null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package io.emeraldpay.dshackle.upstream.bitcoin
|
||||||
|
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.context.Lifecycle
|
||||||
|
import org.zeromq.SocketType
|
||||||
|
import org.zeromq.ZContext
|
||||||
|
import org.zeromq.ZMQ
|
||||||
|
import reactor.core.publisher.Sinks
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.locks.ReentrantLock
|
||||||
|
import kotlin.concurrent.withLock
|
||||||
|
|
||||||
|
class ZMQServer(
|
||||||
|
val host: String,
|
||||||
|
val port: Int,
|
||||||
|
val topic: String,
|
||||||
|
) : Lifecycle {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val log = LoggerFactory.getLogger(ZMQServer::class.java)
|
||||||
|
private val RECEIVE_TIME_MS = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
private val topicId = topic.encodeToByteArray()
|
||||||
|
|
||||||
|
private val runningLock = ReentrantLock()
|
||||||
|
private var running = false
|
||||||
|
private val sinkPublisher = Executors.newSingleThreadExecutor()
|
||||||
|
val sink = Sinks.many()
|
||||||
|
.multicast()
|
||||||
|
.directBestEffort<ByteArray>()
|
||||||
|
|
||||||
|
fun startInternal() = Runnable {
|
||||||
|
log.info("Connecting to ZMQ at $host:$port")
|
||||||
|
val context = ZContext()
|
||||||
|
val socket: ZMQ.Socket = context.createSocket(SocketType.SUB)
|
||||||
|
socket.connect("tcp://$host:$port")
|
||||||
|
socket.subscribe(topic)
|
||||||
|
socket.receiveTimeOut = RECEIVE_TIME_MS
|
||||||
|
|
||||||
|
while (running && !Thread.currentThread().isInterrupted) {
|
||||||
|
val msg = readMessage(socket)
|
||||||
|
if (msg != null) {
|
||||||
|
// this should not happen, but check just in case
|
||||||
|
if (!topicId.contentEquals(msg.id)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sinkPublisher.execute {
|
||||||
|
val sent = sink.tryEmitNext(msg.value)
|
||||||
|
if (sent.isFailure && sent != Sinks.EmitResult.FAIL_ZERO_SUBSCRIBER) {
|
||||||
|
log.warn("Failed to notify with $sent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
socket.close()
|
||||||
|
log.debug("Stopped ZMQ connection to $host:$port")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun readMessage(socket: ZMQ.Socket): Message? {
|
||||||
|
val id = readOnce(socket)
|
||||||
|
val value = readOnce(socket)
|
||||||
|
val seq = readOnce(socket)
|
||||||
|
if (id != null && value != null && seq != null) {
|
||||||
|
return Message(id, value, seq)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun readOnce(socket: ZMQ.Socket): ByteArray? {
|
||||||
|
while (running) {
|
||||||
|
// blocks until a message is received
|
||||||
|
// but usually returns null if nothing received, so have to repeat a request
|
||||||
|
val data = socket.recv(0)
|
||||||
|
if (data != null) {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun start() {
|
||||||
|
runningLock.withLock {
|
||||||
|
if (running) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
running = true
|
||||||
|
}
|
||||||
|
Thread(startInternal()).start()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stop() {
|
||||||
|
runningLock.withLock {
|
||||||
|
if (!running) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
running = false
|
||||||
|
}
|
||||||
|
log.debug("Stopping ZMQ listener at $host:$port")
|
||||||
|
// give some time to the internal thread to receive a message and quit
|
||||||
|
Thread.sleep(RECEIVE_TIME_MS.toLong())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isRunning(): Boolean {
|
||||||
|
return running
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bitcoind produces messages as something like:
|
||||||
|
// | hashblock | <32-byte block hash in Little Endian> | <uint32 sequence number in Little Endian>
|
||||||
|
// i.e., it's a triple of values
|
||||||
|
data class Message(
|
||||||
|
val id: ByteArray,
|
||||||
|
val value: ByteArray,
|
||||||
|
val sequence: ByteArray,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ class ConfiguredUpstreamsSpec extends Specification {
|
|||||||
_ * getDefaultMethods(Chain.ETHEREUM) >> new DefaultEthereumMethods(Chain.ETHEREUM)
|
_ * getDefaultMethods(Chain.ETHEREUM) >> new DefaultEthereumMethods(Chain.ETHEREUM)
|
||||||
}
|
}
|
||||||
def configurer = new ConfiguredUpstreams(
|
def configurer = new ConfiguredUpstreams(
|
||||||
currentUpstreams, Stub(FileResolver), Stub(UpstreamsConfig), Stub(CachesFactory)
|
currentUpstreams, Stub(FileResolver), Stub(UpstreamsConfig)
|
||||||
)
|
)
|
||||||
def methods = new UpstreamsConfig.Methods(
|
def methods = new UpstreamsConfig.Methods(
|
||||||
[
|
[
|
||||||
@@ -42,7 +42,7 @@ class ConfiguredUpstreamsSpec extends Specification {
|
|||||||
_ * getDefaultMethods(Chain.ETHEREUM) >> new DefaultEthereumMethods(Chain.ETHEREUM)
|
_ * getDefaultMethods(Chain.ETHEREUM) >> new DefaultEthereumMethods(Chain.ETHEREUM)
|
||||||
}
|
}
|
||||||
def configurer = new ConfiguredUpstreams(
|
def configurer = new ConfiguredUpstreams(
|
||||||
currentUpstreams, Stub(FileResolver), Stub(UpstreamsConfig), Stub(CachesFactory)
|
currentUpstreams, Stub(FileResolver), Stub(UpstreamsConfig)
|
||||||
)
|
)
|
||||||
def methods = new UpstreamsConfig.Methods(
|
def methods = new UpstreamsConfig.Methods(
|
||||||
[
|
[
|
||||||
|
|||||||
Reference in New Issue
Block a user