solution: esplora upstream connection for bitcoin

This commit is contained in:
Igor Artamonov
2020-09-14 23:08:13 -04:00
parent 46d90ce932
commit 56318d800e
28 changed files with 962 additions and 136 deletions

View File

@@ -19,9 +19,11 @@ import com.fasterxml.jackson.core.Version
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.module.SimpleModule
import io.emeraldpay.dshackle.upstream.bitcoin.data.EsploraUnspent
import io.emeraldpay.dshackle.upstream.bitcoin.data.EsploraUnspentDeserializer
import io.emeraldpay.dshackle.upstream.bitcoin.data.RpcUnspent
import io.emeraldpay.dshackle.upstream.bitcoin.data.RpcUnspentDeserializer
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcResponse
import io.infinitape.etherjar.rpc.json.TransactionReceiptJson
import io.infinitape.etherjar.rpc.json.TransactionReceiptJsonDeserializer
import java.text.SimpleDateFormat
import java.util.*
@@ -36,6 +38,9 @@ class Global {
val module = SimpleModule("EmeraldDshackle", Version(1, 0, 0, null, null, null))
module.addSerializer(JsonRpcResponse::class.java, JsonRpcResponse.ResponseJsonSerializer())
module.addDeserializer(EsploraUnspent::class.java, EsploraUnspentDeserializer())
module.addDeserializer(RpcUnspent::class.java, RpcUnspentDeserializer())
val objectMapper = ObjectMapper()
objectMapper.registerModule(module)
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)

View File

@@ -107,6 +107,7 @@ class UpstreamsConfig {
}
class BitcoinConnection : RpcConnection() {
var esplora: HttpEndpoint? = null
}
class HttpEndpoint(val url: URI) {

View File

@@ -132,6 +132,14 @@ class UpstreamsConfigReader(
http.tls = authConfigReader.readClientTls(node)
}
}
getMapping(connConfigNode, "esplora")?.let { node ->
getValueAsString(node, "url")?.let { url ->
val http = UpstreamsConfig.HttpEndpoint(URI(url))
http.basicAuth = authConfigReader.readClientBasicAuth(node)
http.tls = authConfigReader.readClientTls(node)
connection.esplora = http
}
}
} else {
log.error("Upstream at #0 has invalid configuration")
}

View File

@@ -21,15 +21,16 @@ import io.emeraldpay.dshackle.BlockchainType
import io.emeraldpay.dshackle.SilentException
import io.emeraldpay.dshackle.upstream.MultistreamHolder
import io.emeraldpay.dshackle.upstream.bitcoin.BitcoinMultistream
import io.emeraldpay.dshackle.upstream.bitcoin.data.SimpleUnspent
import io.emeraldpay.grpc.Chain
import org.bitcoinj.params.MainNetParams
import org.bitcoinj.params.TestNet3Params
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.util.function.Tuples
import java.math.BigDecimal
import reactor.core.publisher.Mono
import java.math.BigInteger
import java.util.*
import kotlin.collections.HashMap
@Service
@@ -64,10 +65,18 @@ class TrackBitcoinAddress(
}
fun requestBalances(chain: Chain, api: BitcoinMultistream, addresses: List<String>): Flux<AddressBalance> {
return api.getReader().listUnspent()
.flatMapMany { unspents ->
val result = getTotal(chain, addresses, unspents)
Flux.fromIterable(result)
return Flux.fromIterable(addresses)
.map { Address(chain, it) }
.flatMap { address ->
api.getReader()
.listUnspent(address.bitcoinAddress)
.map { unspents ->
getTotal(address, unspents)
}
.onErrorResume { t ->
log.error("Failed to get unspent", t)
Mono.empty()
}
}
}
@@ -83,37 +92,13 @@ class TrackBitcoinAddress(
.map(this@TrackBitcoinAddress::buildResponse)
}
fun getTotal(chain: Chain, addresses: List<String>, unspents: List<*>): List<AddressBalance> {
return unspents.asSequence()
.filterIsInstance<Map<String, Any>>()
.filter { unspent ->
unspent.containsKey("address")
&& Collections.binarySearch(addresses, unspent["address"] as String) >= 0
&& unspent.containsKey("amount")
}
.map {
Tuples.of(it["address"] as String, it["amount"] as Number)
}
.map {
AddressBalance(chain, it.t1,
//use toString because toDecimal makes rounding
BigDecimal(it.t2.toString()).multiply(BigDecimal.TEN.pow(8)).toBigInteger()
)
}
.plus(
//add default ZERO value
addresses.map {
AddressBalance(chain, it, BigInteger.ZERO)
}
)
.groupBy {
it.address
}
.map {
it.value.reduceRight { x, acc ->
x.plus(acc)
}
}.toList()
fun getTotal(address: Address, unspents: List<SimpleUnspent>): AddressBalance {
val total = if (unspents.isEmpty()) {
0L
} else {
unspents.map { it.value }.reduce(Long::plus)
}
return AddressBalance(address, BigInteger.valueOf(total))
}
@@ -158,5 +143,14 @@ class TrackBitcoinAddress(
fun plus(other: AddressBalance) = AddressBalance(address, balance + other.balance)
}
data class Address(val chain: Chain, val address: String)
class Address(val chain: Chain, val address: String) {
val network = if (chain == Chain.BITCOIN) {
MainNetParams()
} else {
TestNet3Params()
}
val bitcoinAddress = org.bitcoinj.core.Address.fromString(
network, address
)
}
}

View File

@@ -23,6 +23,7 @@ import io.emeraldpay.dshackle.config.UpstreamsConfig
import io.emeraldpay.dshackle.reader.Reader
import io.emeraldpay.dshackle.upstream.CurrentMultistreamHolder
import io.emeraldpay.dshackle.upstream.bitcoin.BitcoinRpcUpstream
import io.emeraldpay.dshackle.upstream.bitcoin.EsploraClient
import io.emeraldpay.dshackle.upstream.calls.CallMethods
import io.emeraldpay.dshackle.upstream.calls.ManagedCallMethods
import io.emeraldpay.dshackle.upstream.ethereum.EthereumRpcUpstream
@@ -142,12 +143,21 @@ open class ConfiguredUpstreams(
return
}
val esplora = conn.esplora?.let { endpoint ->
val tls = endpoint.tls?.let { tls ->
tls.ca?.let { ca ->
fileResolver.resolve(ca).readBytes()
}
}
EsploraClient(endpoint.url, endpoint.basicAuth, tls)
}
val methods = buildMethods(config, chain)
val upstream = BitcoinRpcUpstream(config.id
?: "bitcoin-${seq.getAndIncrement()}", chain, directApi,
options, config.role,
QuorumForLabels.QuorumItem(1, config.labels),
methods)
methods, esplora)
upstream.start()
currentUpstreams.update(UpstreamChange(chain, upstream, UpstreamChange.ChangeType.ADDED))

View File

@@ -0,0 +1,6 @@
package io.emeraldpay.dshackle.upstream
enum class Capability {
RPC,
BALANCE
}

View File

@@ -78,14 +78,14 @@ abstract class Multistream(
*/
fun addUpstream(upstream: Upstream) {
upstreams.add(upstream)
setHead(updateHead())
onUpstreamsUpdated()
setHead(updateHead())
}
fun removeUpstream(id: String) {
if (upstreams.removeIf { it.getId() == id }) {
setHead(updateHead())
onUpstreamsUpdated()
setHead(updateHead())
}
}
@@ -103,7 +103,7 @@ abstract class Multistream(
/**
* Finds an API that executed directly on a remote.
*/
fun getDirectApi(matcher: Selector.Matcher): Mono<Reader<JsonRpcRequest, JsonRpcResponse>> {
open fun getDirectApi(matcher: Selector.Matcher): Mono<Reader<JsonRpcRequest, JsonRpcResponse>> {
val apis = getApiSource(matcher)
apis.request(1)
return Mono.from(apis)
@@ -120,7 +120,7 @@ abstract class Multistream(
throw NotImplementedError("Immediate direct API is not implemented for Aggregated Upstream")
}
fun onUpstreamsUpdated() {
open fun onUpstreamsUpdated() {
reconfigLock.withLock {
getAll().map { it.getMethods() }.let {
//TODO made list of uniq instances, and then if only one, just use it directly

View File

@@ -15,7 +15,6 @@
*/
package io.emeraldpay.dshackle.upstream.bitcoin
import com.fasterxml.jackson.databind.ObjectMapper
import io.emeraldpay.dshackle.cache.Caches
import io.emeraldpay.dshackle.config.UpstreamsConfig
import io.emeraldpay.dshackle.reader.EmptyReader
@@ -38,8 +37,9 @@ open class BitcoinMultistream(
private val log = LoggerFactory.getLogger(BitcoinMultistream::class.java)
}
private var head: Head? = null
private var reader = BitcoinReader(this, EmptyHead())
private var head: Head = EmptyHead()
private var esplora = upstreams.find { it.esploraClient != null }?.esploraClient
private var reader = BitcoinReader(this, head, esplora)
override fun init() {
if (upstreams.size > 0) {
@@ -49,7 +49,7 @@ open class BitcoinMultistream(
}
override fun updateHead(): Head {
head?.let {
head.let {
if (it is Lifecycle) {
it.stop()
}
@@ -81,13 +81,19 @@ open class BitcoinMultistream(
return reader
}
override fun onUpstreamsUpdated() {
super.onUpstreamsUpdated()
esplora = upstreams.find { it.esploraClient != null }?.esploraClient
reader = BitcoinReader(this, this.head, esplora)
}
override fun setHead(head: Head) {
this.head = head
reader = BitcoinReader(this, head)
reader = BitcoinReader(this, head, esplora)
}
override fun getHead(): Head {
return head!!
return head
}
override fun getLabels(): Collection<UpstreamsConfig.Labels> {

View File

@@ -19,8 +19,10 @@ import com.fasterxml.jackson.databind.ObjectMapper
import io.emeraldpay.dshackle.Global
import io.emeraldpay.dshackle.upstream.Head
import io.emeraldpay.dshackle.upstream.Selector
import io.emeraldpay.dshackle.upstream.bitcoin.data.SimpleUnspent
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcRequest
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcResponse
import org.bitcoinj.core.Address
import org.slf4j.LoggerFactory
import org.springframework.context.Lifecycle
import reactor.core.publisher.Mono
@@ -28,7 +30,8 @@ import reactor.kotlin.core.publisher.cast
open class BitcoinReader(
private val upstreams: BitcoinMultistream,
head: Head
head: Head,
esploraClient: EsploraClient?
) : Lifecycle {
companion object {
@@ -38,6 +41,12 @@ open class BitcoinReader(
private val objectMapper: ObjectMapper = Global.objectMapper
private val mempool = CachingMempoolData(upstreams, head)
private val unspentReader: UnspentReader = if (esploraClient != null) {
EsploraUnspentReader(esploraClient, head)
} else {
RpcUnspentReader(upstreams)
}
open fun getMempool(): CachingMempoolData {
return mempool
}
@@ -50,8 +59,8 @@ open class BitcoinReader(
return castedRead(JsonRpcRequest("getrawtransaction", listOf(txid, true)), Map::class.java).cast()
}
open fun listUnspent(): Mono<List<String>> {
return castedRead(JsonRpcRequest("listunspent", emptyList()), List::class.java).cast()
open fun listUnspent(address: Address): Mono<List<SimpleUnspent>> {
return unspentReader.read(address)
}
override fun isRunning(): Boolean {

View File

@@ -34,8 +34,9 @@ open class BitcoinRpcUpstream(
options: UpstreamsConfig.Options,
role: UpstreamsConfig.UpstreamRole,
node: QuorumForLabels.QuorumItem,
callMethods: CallMethods
) : BitcoinUpstream(id, chain, options, role, callMethods, node), Lifecycle {
callMethods: CallMethods,
esploraClient: EsploraClient? = null
) : BitcoinUpstream(id, chain, options, role, callMethods, node, esploraClient), Lifecycle {
companion object {
private val log = LoggerFactory.getLogger(BitcoinRpcUpstream::class.java)

View File

@@ -29,7 +29,8 @@ abstract class BitcoinUpstream(
options: UpstreamsConfig.Options,
role: UpstreamsConfig.UpstreamRole,
callMethods: CallMethods,
node: QuorumForLabels.QuorumItem
node: QuorumForLabels.QuorumItem,
val esploraClient: EsploraClient? = null
) : DefaultUpstream(id, options, role, callMethods, node) {
constructor(id: String,

View File

@@ -0,0 +1,104 @@
/**
* Copyright (c) 2020 EmeraldPay, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.emeraldpay.dshackle.upstream.bitcoin
import io.emeraldpay.dshackle.Global
import io.emeraldpay.dshackle.config.AuthConfig
import io.emeraldpay.dshackle.upstream.bitcoin.data.EsploraUnspent
import io.netty.handler.codec.http.HttpHeaderNames
import io.netty.handler.codec.http.HttpHeaders
import io.netty.handler.ssl.SslContextBuilder
import org.bitcoinj.core.Address
import org.slf4j.LoggerFactory
import reactor.core.publisher.Mono
import reactor.netty.http.client.HttpClient
import java.io.ByteArrayInputStream
import java.net.URI
import java.security.KeyStore
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.util.*
import java.util.function.Consumer
class EsploraClient(
private val url: URI,
basicAuth: AuthConfig.ClientBasicAuth? = null,
tlsCAAuth: ByteArray? = null
) {
companion object {
private val log = LoggerFactory.getLogger(EsploraClient::class.java)
}
private val httpClient: HttpClient
init {
var build = HttpClient.create()
build = build.headers { h ->
h.add(HttpHeaderNames.CONTENT_TYPE, "application/json")
}
basicAuth?.let { auth ->
val authString: String = auth.username + ":" + auth.password
val authBase64 = Base64.getEncoder().encodeToString(authString.toByteArray())
val encodedAuth = "Basic $authBase64"
val headers = Consumer { h: HttpHeaders -> h.add(HttpHeaderNames.AUTHORIZATION, encodedAuth) }
build = build.headers(headers)
}
tlsCAAuth?.let { auth ->
val cf = CertificateFactory.getInstance("X.509")
val cert = cf.generateCertificate(ByteArrayInputStream(auth)) as X509Certificate
val ks = KeyStore.getInstance(KeyStore.getDefaultType())
ks.load(null, "".toCharArray())
ks.setCertificateEntry("server", cert)
val sslContext = SslContextBuilder.forClient().trustManager(cert).build()
build.secure { spec ->
spec.sslContext(sslContext)
}
}
this.httpClient = build
}
fun getUtxo(address: Address): Mono<List<EsploraUnspent>> {
val response = httpClient
.get()
.uri("$url/address/$address/utxo")
val json = response
.response { header, bytes ->
if (header.status().code() != 200) {
Mono.error(EsploraException("HTTP Code: ${header.status().code()}"))
} else {
bytes.aggregate().asByteArray()
}
}
.single()
.map { bytes -> String(bytes) }
return json.map {
val parsed = Global.objectMapper.readerFor(EsploraUnspent::class.java)
.readValues<EsploraUnspent>(it);
parsed.readAll()
}
}
class EsploraException(msg: String) : Exception(msg)
}

View File

@@ -0,0 +1,53 @@
/**
* Copyright (c) 2020 EmeraldPay, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.emeraldpay.dshackle.upstream.bitcoin
import io.emeraldpay.dshackle.upstream.Head
import io.emeraldpay.dshackle.upstream.bitcoin.data.EsploraUnspent
import io.emeraldpay.dshackle.upstream.bitcoin.data.SimpleUnspent
import org.bitcoinj.core.Address
import org.slf4j.LoggerFactory
import reactor.core.publisher.Mono
import java.util.function.Function
class EsploraUnspentReader(
private val esploraClient: EsploraClient,
private val head: Head
) : UnspentReader {
companion object {
private val log = LoggerFactory.getLogger(EsploraUnspentReader::class.java)
}
private val convert: (T: EsploraUnspent) -> SimpleUnspent = { base ->
SimpleUnspent(
base.txid,
base.vout,
base.value,
head.getCurrentHeight()?.let { base.height - it } ?: 0
)
}
private val convertAll: Function<List<EsploraUnspent>, List<SimpleUnspent>> = Function { base ->
base.map(convert)
}
override fun read(key: Address): Mono<List<SimpleUnspent>> {
return esploraClient.getUtxo(key)
.map(convertAll)
}
}

View File

@@ -0,0 +1,61 @@
/**
* Copyright (c) 2020 EmeraldPay, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.emeraldpay.dshackle.upstream.bitcoin
import io.emeraldpay.dshackle.Global
import io.emeraldpay.dshackle.upstream.Selector
import io.emeraldpay.dshackle.upstream.bitcoin.data.RpcUnspent
import io.emeraldpay.dshackle.upstream.bitcoin.data.SimpleUnspent
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcRequest
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcResponse
import org.bitcoinj.core.Address
import org.slf4j.LoggerFactory
import reactor.core.publisher.Mono
class RpcUnspentReader(
private val upstreams: BitcoinMultistream
) : UnspentReader {
companion object {
private val log = LoggerFactory.getLogger(RpcUnspentReader::class.java)
}
private val convert: (T: RpcUnspent) -> SimpleUnspent = { base ->
SimpleUnspent(
base.txid,
base.vout,
base.amount,
base.confirmations
)
}
override fun read(key: Address): Mono<List<SimpleUnspent>> {
val address = key.toString()
return upstreams.getDirectApi(Selector.empty).flatMap { api ->
api.read(JsonRpcRequest("listunspent", emptyList()))
.flatMap(JsonRpcResponse::requireResult)
.map {
Global.objectMapper.readerFor(RpcUnspent::class.java).readValues<RpcUnspent>(it).readAll()
}
.map {
it.filter {
it.address == address
}.map(convert)
}
}
}
}

View File

@@ -0,0 +1,23 @@
/**
* Copyright (c) 2020 EmeraldPay, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.emeraldpay.dshackle.upstream.bitcoin
import io.emeraldpay.dshackle.reader.Reader
import io.emeraldpay.dshackle.upstream.bitcoin.data.SimpleUnspent
import org.bitcoinj.core.Address
interface UnspentReader : Reader<Address, List<SimpleUnspent>> {
}

View File

@@ -0,0 +1,26 @@
/**
* Copyright (c) 2020 EmeraldPay, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.emeraldpay.dshackle.upstream.bitcoin.data
import java.time.Instant
data class EsploraUnspent(
val txid: String,
val vout: Int,
val value: Long,
val timestamp: Instant,
val height: Long
)

View File

@@ -0,0 +1,36 @@
/**
* Copyright (c) 2020 EmeraldPay, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.emeraldpay.dshackle.upstream.bitcoin.data
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonDeserializer
import com.fasterxml.jackson.databind.JsonNode
import java.time.Instant
class EsploraUnspentDeserializer : JsonDeserializer<EsploraUnspent>() {
override fun deserialize(jp: JsonParser, ctxt: DeserializationContext): EsploraUnspent {
val node: JsonNode = jp.readValueAsTree()
val status = node.get("status")
return EsploraUnspent(
node.get("txid").asText(),
node.get("vout").asInt(),
node.get("value").asLong(),
Instant.ofEpochSecond(status.get("block_time").asLong()),
status.get("block_height").asLong()
)
}
}

View File

@@ -0,0 +1,24 @@
/**
* Copyright (c) 2020 EmeraldPay, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.emeraldpay.dshackle.upstream.bitcoin.data
data class RpcUnspent(
val txid: String,
val vout: Int,
val address: String,
val amount: Long,
val confirmations: Long
)

View File

@@ -0,0 +1,37 @@
/**
* Copyright (c) 2020 EmeraldPay, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.emeraldpay.dshackle.upstream.bitcoin.data
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonDeserializer
import com.fasterxml.jackson.databind.JsonNode
import java.math.BigDecimal
class RpcUnspentDeserializer : JsonDeserializer<RpcUnspent>() {
override fun deserialize(jp: JsonParser, ctxt: DeserializationContext): RpcUnspent {
val node: JsonNode = jp.readValueAsTree()
return RpcUnspent(
node.get("txid").asText(),
node.get("vout").asInt(),
node.get("address").asText(),
BigDecimal(node.get("amount").asText()).multiply(BigDecimal.TEN.pow(8)).longValueExact(),
node.get("confirmations").asLong()
)
}
}

View File

@@ -0,0 +1,23 @@
/**
* Copyright (c) 2020 EmeraldPay, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.emeraldpay.dshackle.upstream.bitcoin.data
data class SimpleUnspent(
val txid: String,
val vout: Int,
val value: Long,
val confirmations: Long
)

View File

@@ -74,6 +74,61 @@ class UpstreamsConfigReaderSpec extends Specification {
}
}
def "Parse bitcoin upstreams"() {
setup:
def config = this.class.getClassLoader().getResourceAsStream("upstreams-bitcoin.yaml")
when:
def act = reader.read(config)
then:
act != null
with(act.defaultOptions) {
size() == 1
with(get(0)) {
chains == ["bitcoin"]
options.minPeers == 3
}
}
act.upstreams.size() == 1
with(act.upstreams.get(0)) {
id == "local"
chain == "bitcoin"
connection instanceof UpstreamsConfig.BitcoinConnection
with((UpstreamsConfig.BitcoinConnection) connection) {
rpc != null
rpc.url == new URI("http://localhost:8545")
esplora == null
}
}
}
def "Parse bitcoin upstreams with esplora"() {
setup:
def config = this.class.getClassLoader().getResourceAsStream("upstreams-bitcoin-esplora.yaml")
when:
def act = reader.read(config)
then:
act != null
with(act.defaultOptions) {
size() == 1
with(get(0)) {
chains == ["bitcoin"]
options.minPeers == 3
}
}
act.upstreams.size() == 1
with(act.upstreams.get(0)) {
id == "local"
chain == "bitcoin"
connection instanceof UpstreamsConfig.BitcoinConnection
with((UpstreamsConfig.BitcoinConnection) connection) {
rpc != null
rpc.url == new URI("http://localhost:8545")
esplora != null
esplora.url == new URI("http://localhost:3001")
}
}
}
def "Parse ds config"() {
setup:
def config = this.class.getClassLoader().getResourceAsStream("upstreams-ds.yaml")

View File

@@ -27,6 +27,7 @@ import io.emeraldpay.dshackle.upstream.Head
import io.emeraldpay.dshackle.upstream.MultistreamHolder
import io.emeraldpay.dshackle.upstream.bitcoin.BitcoinMultistream
import io.emeraldpay.dshackle.upstream.bitcoin.BitcoinReader
import io.emeraldpay.dshackle.upstream.bitcoin.data.SimpleUnspent
import io.emeraldpay.grpc.Chain
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
@@ -42,89 +43,37 @@ class TrackBitcoinAddressSpec extends Specification {
String hash1 = "0xa0e65cbc1b52a8ca60562112c6060552d882f16f34a9dba2ccdc05c0a6a27c22"
ObjectMapper objectMapper = Global.objectMapper
def "Correct sum from multiple"() {
def "Correct sum for single"() {
setup:
def json = this.class.getClassLoader().getResourceAsStream("bitcoin/unspent-one-addr.json")
def unspents = objectMapper.readValue(json, List)
def unspents = [
new SimpleUnspent("f14b222e652c58d11435fa9172ddea000c6f5e20e6b715eb940fc28d1c4adeef", 0, 100L, 123L)
]
TrackBitcoinAddress track = new TrackBitcoinAddress(Stub(MultistreamHolder))
def address = new TrackBitcoinAddress.Address(
Chain.BITCOIN, "1K7xkspJg7DDKNwzXgoRSDCUxiFsRegsSK"
)
when:
def total = track.getTotal(Chain.BITCOIN, ["1K7xkspJg7DDKNwzXgoRSDCUxiFsRegsSK"], unspents)
def total = track.getTotal(address, unspents)
then:
total.size() == 1
total[0].address.chain == Chain.BITCOIN
total[0].address.address == "1K7xkspJg7DDKNwzXgoRSDCUxiFsRegsSK"
total[0].balance.toString() == "32928461"
total.balance == 100
}
def "Correct sum when other addresses"() {
def "Correct sum for few"() {
setup:
def json = this.class.getClassLoader().getResourceAsStream("bitcoin/unspent-two-addr.json")
def unspents = objectMapper.readValue(json, List)
def unspents = [
new SimpleUnspent("f14b222e652c58d11435fa9172ddea000c6f5e20e6b715eb940fc28d1c4adeef", 0, 100L, 123L),
new SimpleUnspent("f14b222e652c58d11435fa9172ddea000c6f5e20e6b715eb940fc28d1c4adeef", 0, 123L, 123L),
]
TrackBitcoinAddress track = new TrackBitcoinAddress(Stub(MultistreamHolder))
def address = new TrackBitcoinAddress.Address(
Chain.BITCOIN, "1K7xkspJg7DDKNwzXgoRSDCUxiFsRegsSK"
)
when:
def total = track.getTotal(Chain.BITCOIN, ["1K7xkspJg7DDKNwzXgoRSDCUxiFsRegsSK"], unspents)
def total = track.getTotal(address, unspents)
then:
total.size() == 1
total[0].address.chain == Chain.BITCOIN
total[0].address.address == "1K7xkspJg7DDKNwzXgoRSDCUxiFsRegsSK"
total[0].balance.toString() == "32928461"
}
def "Sum for two addresses"() {
setup:
def json = this.class.getClassLoader().getResourceAsStream("bitcoin/unspent-two-addr.json")
def unspents = objectMapper.readValue(json, List)
TrackBitcoinAddress track = new TrackBitcoinAddress(Stub(MultistreamHolder))
when:
def total = track.getTotal(Chain.BITCOIN, ["1K7xkspJg7DDKNwzXgoRSDCUxiFsRegsSK", "35hK24tcLEWcgNA4JxpvbkNkoAcDGqQPsP"], unspents).sort { it.address.address }
then:
total.size() == 2
with(total[0]) {
address.chain == Chain.BITCOIN
address.address == "1K7xkspJg7DDKNwzXgoRSDCUxiFsRegsSK"
balance.toString() == "32928461"
}
with(total[1]) {
address.chain == Chain.BITCOIN
address.address == "35hK24tcLEWcgNA4JxpvbkNkoAcDGqQPsP"
balance.toString() == "25550215615737"
}
}
def "Zero for empty unspents"() {
setup:
TrackBitcoinAddress track = new TrackBitcoinAddress(Stub(MultistreamHolder))
when:
def total = track.getTotal(Chain.BITCOIN, ["1K7xkspJg7DDKNwzXgoRSDCUxiFsRegsSK"], [])
then:
total.size() == 1
total[0].address.chain == Chain.BITCOIN
total[0].address.address == "1K7xkspJg7DDKNwzXgoRSDCUxiFsRegsSK"
total[0].balance.toString() == "0"
}
def "Zero for unknown address"() {
setup:
def json = this.class.getClassLoader().getResourceAsStream("bitcoin/unspent-two-addr.json")
def unspents = objectMapper.readValue(json, List)
TrackBitcoinAddress track = new TrackBitcoinAddress(Stub(MultistreamHolder))
when:
def total = track.getTotal(Chain.BITCOIN, ["16rCmCmbuWDhPjWTrpQGaU3EPdZF7MTdUk", "1K7xkspJg7DDKNwzXgoRSDCUxiFsRegsSK"], unspents).sort { it.address.address }
then:
total.size() == 2
with(total[0]) {
address.address == "16rCmCmbuWDhPjWTrpQGaU3EPdZF7MTdUk"
balance.toString() == "0"
}
with(total[1]) {
address.address == "1K7xkspJg7DDKNwzXgoRSDCUxiFsRegsSK"
balance.toString() == "32928461"
}
total.balance == 223
}
def "One address for single provided"() {
@@ -226,9 +175,11 @@ class TrackBitcoinAddressSpec extends Specification {
def upstream = null
upstream = Mock(BitcoinMultistream) {
_ * getReader() >> Mock(BitcoinReader) {
2 * listUnspent() >>> [
2 * listUnspent(_) >>> [
Mono.just([]),
Mono.just([[address: "1K7xkspJg7DDKNwzXgoRSDCUxiFsRegsSK", amount: 0.0123]])
Mono.just([
new SimpleUnspent("f14b222e652c58d11435fa9172ddea000c6f5e20e6b715eb940fc28d1c4adeef", 0, 1230000L, 123L)
])
]
}
_ * getHead() >> head

View File

@@ -0,0 +1,83 @@
package io.emeraldpay.dshackle.upstream.bitcoin
/**
* Copyright (c) 2020 EmeraldPay, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import io.emeraldpay.dshackle.upstream.bitcoin.data.EsploraUnspent
import org.bitcoinj.core.Address
import org.bitcoinj.params.MainNetParams
import org.mockserver.integration.ClientAndServer
import org.mockserver.model.HttpRequest
import org.mockserver.model.HttpResponse
import reactor.test.StepVerifier
import spock.lang.Specification
import java.time.Duration
import java.time.Instant
class EsploraClientSpec extends Specification {
ClientAndServer mockServer
def setup() {
mockServer = ClientAndServer.startClientAndServer(23001);
}
def cleanup() {
mockServer.stop()
}
def "get utxo"() {
setup:
def responseJson = this.class.getClassLoader().getResourceAsStream("bitcoin/esplora-utxo-1.json").text
mockServer.when(
HttpRequest.request()
.withMethod("GET")
.withPath("/address/35vktkPo4wdK8Twu4VMiuPLdCx23XEykGY/utxo")
).respond(
HttpResponse.response(responseJson)
)
def client = new EsploraClient(new URI("http://localhost:23001"), null, null)
when:
def act = client.getUtxo(Address.fromString(new MainNetParams(), "35vktkPo4wdK8Twu4VMiuPLdCx23XEykGY"))
then:
StepVerifier.create(act)
.expectNextMatches { list ->
def ok = list.size() == 14
if (!ok) println("invalid size")
ok = ok && list[0] == new EsploraUnspent("002eba7d9e8081afc687e1c3fa7b6a6451713b75bc91432d441bb1e7e1511c5c", 0, 105524, Instant.ofEpochSecond(1599808442), 647721)
if (!ok) println("invalid 0")
ok = ok && list[1] == new EsploraUnspent("1ee3cb3833b1e499e7f8babb1100c4aa0b4273f655036561d84dd72a5b197258", 0, 5248, Instant.ofEpochSecond(1600114632), 648312)
if (!ok) println("invalid 1")
ok = ok && list[12] == new EsploraUnspent("311d0d1d4eea6ee37d57954cd1002898bef64885d8e438afbbd7fe4fdc6e08de", 2250, 22978, Instant.ofEpochSecond(1599018027), 646382)
if (!ok) println("invalid 12")
ok = ok && list[13] == new EsploraUnspent("230317b8fdf1ae85f5fbdc49ca90851b1728c2d9432b2738d3fe1c6f68f046e4", 11, 8642, Instant.ofEpochSecond(1599273442), 646777)
if (!ok) println("invalid 13")
ok
}
.expectComplete()
.verify(Duration.ofSeconds(3))
when:
def actTotal = client.getUtxo(Address.fromString(new MainNetParams(), "35vktkPo4wdK8Twu4VMiuPLdCx23XEykGY"))
.block()
.sum { it.value }
then:
actTotal == 309841L
}
}

View File

@@ -0,0 +1,135 @@
/**
* Copyright (c) 2020 EmeraldPay, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.emeraldpay.dshackle.upstream.bitcoin
import io.emeraldpay.dshackle.reader.Reader
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcRequest
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcResponse
import org.bitcoinj.core.Address
import org.bitcoinj.params.MainNetParams
import reactor.core.publisher.Mono
import spock.lang.Specification
class RpcUnspentReaderSpec extends Specification {
def "all if single address"() {
setup:
def json = this.class.getClassLoader().getResourceAsStream("bitcoin/unspent-one-addr.json").bytes
def rpcReader = Mock(Reader) {
1 * read(new JsonRpcRequest("listunspent", [])) >> Mono.just(JsonRpcResponse.ok(json))
}
def upstreams = Mock(BitcoinMultistream) {
1 * getDirectApi(_) >> Mono.just(rpcReader)
}
def reader = new RpcUnspentReader(upstreams)
when:
def act = reader.read(Address.fromString(new MainNetParams(), "1K7xkspJg7DDKNwzXgoRSDCUxiFsRegsSK")).block()
then:
// cat src/test/resources/bitcoin/unspent-one-addr.json | jq '. | length'
act.size() == 36
with(act[0]) {
txid == "e0f946c8f971b25cdffa64eed71d886019e437c0bf6a1b280584c0be5d1b5409"
vout == 29
confirmations == 2010
value == 1230030
}
with(act[1]) {
txid == "66e1e4d14ed6f454d2fda036f35cba423274ecdf5d46deb93f172c412a0f650d"
vout == 83
confirmations == 4963
value == 756339
}
with(act[35]) {
txid == "f14b222e652c58d11435fa9172ddea000c6f5e20e6b715eb940fc28d1c4adeef"
vout == 58
confirmations == 2139
value == 1105047
}
}
def "select if two addresses - 35hK"() {
setup:
def json = this.class.getClassLoader().getResourceAsStream("bitcoin/unspent-two-addr.json").bytes
def rpcReader = Mock(Reader) {
1 * read(new JsonRpcRequest("listunspent", [])) >> Mono.just(JsonRpcResponse.ok(json))
}
def upstreams = Mock(BitcoinMultistream) {
1 * getDirectApi(_) >> Mono.just(rpcReader)
}
def reader = new RpcUnspentReader(upstreams)
when:
def act = reader.read(Address.fromString(new MainNetParams(), "35hK24tcLEWcgNA4JxpvbkNkoAcDGqQPsP")).block()
then:
// cat src/test/resources/bitcoin/unspent-two-addr.json | jq '[.[] | select(.address == "35hK24tcLEWcgNA4JxpvbkNkoAcDGqQPsP")] | length'
act.size() == 340
with(act[0]) {
txid == "8ad0d954a01eeb4f2c62d58d291699af847f9c8df43b775c27ffe8a5f76eba00"
vout == 1
value == 216465
confirmations == 2583
}
with(act[11]) {
txid == "777671a46b30b068052a73387e036bc8515cd3ba6adf9be4c70dfc0699f67c09"
vout == 0
confirmations == 13705
value == 307906
}
// cat src/test/resources/bitcoin/unspent-two-addr.json | jq '[.[] | select(.address == "35hK24tcLEWcgNA4JxpvbkNkoAcDGqQPsP")] | .[211]'
with(act[211]) {
txid == "f20727393b0a586a3062a615fb71f43ec21c24258c3c6ec546fee5cbc1fa2ba7"
vout == 0
confirmations == 21890
value == 50000000000
}
}
def "select if two addresses - 1K7x"() {
setup:
def json = this.class.getClassLoader().getResourceAsStream("bitcoin/unspent-two-addr.json").bytes
def rpcReader = Mock(Reader) {
1 * read(new JsonRpcRequest("listunspent", [])) >> Mono.just(JsonRpcResponse.ok(json))
}
def upstreams = Mock(BitcoinMultistream) {
1 * getDirectApi(_) >> Mono.just(rpcReader)
}
def reader = new RpcUnspentReader(upstreams)
when:
def act = reader.read(Address.fromString(new MainNetParams(), "1K7xkspJg7DDKNwzXgoRSDCUxiFsRegsSK")).block()
then:
// cat src/test/resources/bitcoin/unspent-two-addr.json | jq '[.[] | select(.address == "1K7xkspJg7DDKNwzXgoRSDCUxiFsRegsSK")] | length'
act.size() == 36
// cat src/test/resources/bitcoin/unspent-two-addr.json | jq '[.[] | select(.address == "1K7xkspJg7DDKNwzXgoRSDCUxiFsRegsSK")] | .[0]'
with(act[0]) {
txid == "e0f946c8f971b25cdffa64eed71d886019e437c0bf6a1b280584c0be5d1b5409"
vout == 29
value == 1230030
confirmations == 2030
}
// cat src/test/resources/bitcoin/unspent-two-addr.json | jq '[.[] | select(.address == "1K7xkspJg7DDKNwzXgoRSDCUxiFsRegsSK")] | .[35]'
with(act[35]) {
txid == "f14b222e652c58d11435fa9172ddea000c6f5e20e6b715eb940fc28d1c4adeef"
vout == 58
confirmations == 2159
value == 1105047
}
}
}

View File

@@ -31,9 +31,10 @@ import java.time.Duration
class JsonRpcHttpClientSpec extends Specification {
ClientAndServer mockServer
int port = 19332
def setup() {
mockServer = ClientAndServer.startClientAndServer(18332);
mockServer = ClientAndServer.startClientAndServer(port);
}
def cleanup() {
@@ -42,7 +43,7 @@ class JsonRpcHttpClientSpec extends Specification {
def "Make a request"() {
setup:
JsonRpcHttpClient client = new JsonRpcHttpClient("localhost:18332", null, null)
JsonRpcHttpClient client = new JsonRpcHttpClient("localhost:${port}", null, null)
def resp = '{' +
' "jsonrpc": "2.0",' +
' "result": "0x98de45",' +
@@ -64,7 +65,7 @@ class JsonRpcHttpClientSpec extends Specification {
def "Make request with basic auth"() {
setup:
def auth = new AuthConfig.ClientBasicAuth("user", "passwd")
def client = new JsonRpcHttpClient("localhost:18332", auth, null)
def client = new JsonRpcHttpClient("localhost:${port}", auth, null)
mockServer.when(
HttpRequest.request()
@@ -92,7 +93,7 @@ class JsonRpcHttpClientSpec extends Specification {
def "Produces RPC Exception on error status code"() {
setup:
def client = new JsonRpcHttpClient("localhost:18332", null, null)
def client = new JsonRpcHttpClient("localhost:${port}", null, null)
mockServer.when(
HttpRequest.request()

View File

@@ -0,0 +1,156 @@
[
{
"txid": "002eba7d9e8081afc687e1c3fa7b6a6451713b75bc91432d441bb1e7e1511c5c",
"vout": 0,
"status": {
"confirmed": true,
"block_height": 647721,
"block_hash": "00000000000000000000dfcec4f7a9bfb8e866a1ce84c65413f823a30d58be65",
"block_time": 1599808442
},
"value": 105524
},
{
"txid": "1ee3cb3833b1e499e7f8babb1100c4aa0b4273f655036561d84dd72a5b197258",
"vout": 0,
"status": {
"confirmed": true,
"block_height": 648312,
"block_hash": "000000000000000000013ae68460e47a6336e936c39ce509c5eb962d7982b3af",
"block_time": 1600114632
},
"value": 5248
},
{
"txid": "e21f15131c6fedc165d2daca98043430e6c526f0e3b3d6821f6dc27e450f3eb2",
"vout": 724,
"status": {
"confirmed": true,
"block_height": 645601,
"block_hash": "000000000000000000042e6455477ef88859b630ccb740e675326e7679485fb3",
"block_time": 1598566638
},
"value": 59539
},
{
"txid": "c14d13fcabfd701a86c6fdac7cd9dc9abd849538308e6526539791ad6befafec",
"vout": 429,
"status": {
"confirmed": true,
"block_height": 647600,
"block_hash": "00000000000000000002864bf9affc84973de0b0099659ad06ae463ac9e3db04",
"block_time": 1599736973
},
"value": 10548
},
{
"txid": "7c9a77c8a6d4a02afca1dad358098cad6d7b0990079fdb0d6d6289a0538974fd",
"vout": 165,
"status": {
"confirmed": true,
"block_height": 646920,
"block_hash": "0000000000000000000435313e49202f818d6bf9bea6a2864b6da4d8457b7156",
"block_time": 1599363124
},
"value": 17050
},
{
"txid": "9dd94f926ba50850819fd7a5f07b93fb0d4bbdebf9c80cd5ccd1efd1af65a43d",
"vout": 707,
"status": {
"confirmed": true,
"block_height": 647228,
"block_hash": "0000000000000000000ea8779c8c4d5f973c49fa7c7e7e6acca789240b349582",
"block_time": 1599522964
},
"value": 7383
},
{
"txid": "10d2f54b99e20abec3b84aaffdbe6214d73bb7c6200350fb1562fd5ef4143d69",
"vout": 812,
"status": {
"confirmed": true,
"block_height": 647013,
"block_hash": "0000000000000000001005dcd53f484fb34bd114eb676d2fb681255ce5877071",
"block_time": 1599408073
},
"value": 18941
},
{
"txid": "78449f63c1d99deeb038afd49acd46a62e570cdbcfff18c29a57112798c19557",
"vout": 454,
"status": {
"confirmed": true,
"block_height": 647371,
"block_hash": "00000000000000000000d9efae5d8b23187fef9352791576b7355ceeff14f46a",
"block_time": 1599602375
},
"value": 9735
},
{
"txid": "36a77970d6c0355d3734c6b41dc7f799c7e48c7e508737486bac89848604dbb3",
"vout": 378,
"status": {
"confirmed": true,
"block_height": 647678,
"block_hash": "0000000000000000000b4f64c1e59a81bc6241faa4bf777b0cd84fe2080d290d",
"block_time": 1599776128
},
"value": 27053
},
{
"txid": "b1b301adb1b46e309bf62d8f4aee4dbf481d7d9008c5d1e840feed78f8fea658",
"vout": 1429,
"status": {
"confirmed": true,
"block_height": 647852,
"block_hash": "0000000000000000000da3a1f8d23df1f9a7e89ab6e348c37cdb056b659f8e71",
"block_time": 1599880147
},
"value": 7244
},
{
"txid": "6733d19b07b0fa0b22ea6fe7b23371ecfe9f6f1b8d5884a3fe9a1db786bef3df",
"vout": 446,
"status": {
"confirmed": true,
"block_height": 648090,
"block_hash": "00000000000000000008b2f35ae277da04d5b1e7b55d7a20fb4190b21c1cfb74",
"block_time": 1600001410
},
"value": 4073
},
{
"txid": "01588730a39a90b30321d19b451333b1f24302458da0f5e851ce13a912caa142",
"vout": 303,
"status": {
"confirmed": true,
"block_height": 646625,
"block_hash": "0000000000000000000de3b0f43dcd0870be269a1326f3cbb4b692490cfa78aa",
"block_time": 1599175143
},
"value": 5883
},
{
"txid": "311d0d1d4eea6ee37d57954cd1002898bef64885d8e438afbbd7fe4fdc6e08de",
"vout": 2250,
"status": {
"confirmed": true,
"block_height": 646382,
"block_hash": "0000000000000000000393bc7709a85b572b2ea239ed07002b230e20b5f47699",
"block_time": 1599018027
},
"value": 22978
},
{
"txid": "230317b8fdf1ae85f5fbdc49ca90851b1728c2d9432b2738d3fe1c6f68f046e4",
"vout": 11,
"status": {
"confirmed": true,
"block_height": 646777,
"block_hash": "0000000000000000000dab9a4fab9d612f402f491dad9aad6ea94b57995e45a1",
"block_time": 1599273442
},
"value": 8642
}
]

View File

@@ -0,0 +1,17 @@
version: v1
defaults:
- chains:
- bitcoin
options:
min-peers: 3
upstreams:
- id: local
chain: bitcoin
connection:
bitcoin:
rpc:
url: "http://localhost:8545"
esplora:
url: "http://localhost:3001"

View File

@@ -1,6 +1,6 @@
version: v1
defaultOptions:
defaults:
- chains:
- bitcoin
options: