Merge pull request #56 from emeraldpay/feat/electrs-index

Electrs upstream
This commit is contained in:
Igor Artamonov
2020-09-15 23:42:55 -04:00
committed by GitHub
34 changed files with 1772 additions and 166 deletions

View File

@@ -45,7 +45,6 @@ image::dshackle-intro.png[alt="",width=80%,align="center"]
- [ ] External logging
- [ ] Prometheus monitoring
- [ ] Subscription to bitcoind notification over gRPC (instead of ZeroMQ)
- [ ] BIP-32 Pubkey
- [ ] Lightweight sidecar node connector
- [ ] Configurable upstream roles

View File

@@ -57,7 +57,7 @@ configurations {
}
dependencies {
implementation "io.emeraldpay:emerald-api:0.7.1"
implementation "io.emeraldpay:emerald-api:0.8.0"
implementation "io.grpc:grpc-protobuf:${grpcVersion}"
implementation "io.grpc:grpc-stub:${grpcVersion}"

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
@@ -46,97 +47,105 @@ class TrackBitcoinAddress(
&& BlockchainType.fromBlockchain(chain) == BlockchainType.BITCOIN && multistreamHolder.isAvailable(chain)
}
fun allAddresses(request: BlockchainOuterClass.BalanceRequest): List<String>? {
fun allAddresses(api: BitcoinMultistream, request: BlockchainOuterClass.BalanceRequest): Flux<String> {
if (!request.hasAddress()) {
return null
return Flux.empty()
}
return when {
request.address.hasAddressXpub() -> {
val xpubAddresses = api.getXpubAddresses()
?: return Flux.error(IllegalStateException("Xpub verification is not available"))
val addressXpub = request.address.addressXpub
if (!addressXpub.xpub.isValidUtf8) {
return Flux.error(IllegalArgumentException("Invalid xpub string"))
}
val xpub = addressXpub.xpub.toStringUtf8()
val start = Math.max(0, addressXpub.start).toInt()
val limit = Math.min(100, Math.max(1, addressXpub.limit)).toInt()
xpubAddresses.activeAddresses(xpub, start, limit)
.map { it.toString() }
.doOnError { t -> log.error("Failed to process xpub. ${t.javaClass}:${t.message}") }
}
request.address.hasAddressSingle() -> {
listOf(request.address.addressSingle.address)
Flux.just(request.address.addressSingle.address)
}
request.address.hasAddressMulti() -> {
request.address.addressMulti.addressesList
.map { addr -> addr.address }
.sorted()
Flux.fromIterable(
request.address.addressMulti.addressesList
.map { addr -> addr.address }
//TODO why sorted?
.sorted()
)
}
else -> null
else -> Flux.error(IllegalArgumentException("Unsupported address type"))
}
}
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)
fun requestBalances(chain: Chain, api: BitcoinMultistream, addresses: Flux<String>, includeUtxo: Boolean): Flux<AddressBalance> {
return addresses
.map { Address(chain, it) }
.flatMap { address ->
balanceForAddress(api, address, includeUtxo)
}
}
fun balanceForAddress(api: BitcoinMultistream, address: Address, includeUtxo: Boolean): Mono<AddressBalance> {
return api.getReader()
.listUnspent(address.bitcoinAddress)
.map { unspent ->
totalUnspent(address, includeUtxo, unspent)
}
.onErrorResume { t ->
log.error("Failed to get unspent", t)
Mono.empty()
}
}
fun totalUnspent(address: Address, includeUtxo: Boolean, unspent: List<SimpleUnspent>): AddressBalance {
return if (unspent.isEmpty()) {
AddressBalance(address, BigInteger.ZERO)
} else {
unspent.map {
AddressBalance(
address,
BigInteger.valueOf(it.value),
if (includeUtxo) listOf(BalanceUtxo(it.txid, it.vout, it.value))
else emptyList()
)
}.reduce { a, b -> a.plus(b) }
}
}
override fun getBalance(request: BlockchainOuterClass.BalanceRequest): Flux<BlockchainOuterClass.AddressBalance> {
val chain = Chain.byId(request.asset.chainValue)
val upstream = multistreamHolder.getUpstream(chain)?.cast(BitcoinMultistream::class.java)
?: return Flux.error(SilentException.UnsupportedBlockchain(request.asset.chainValue))
val addresses = allAddresses(request) ?: return Flux.error(SilentException("Unsupported address"))
if (addresses.isEmpty()) {
return Flux.empty()
}
return requestBalances(chain, upstream, addresses)
val addresses = allAddresses(upstream, request) ?: return Flux.error(SilentException("Unsupported address"))
return requestBalances(chain, upstream, addresses, request.includeUtxo)
.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()
}
override fun subscribe(request: BlockchainOuterClass.BalanceRequest): Flux<BlockchainOuterClass.AddressBalance> {
val chain = Chain.byId(request.asset.chainValue)
val upstream = multistreamHolder.getUpstream(chain)?.cast(BitcoinMultistream::class.java)
?: return Flux.error(SilentException.UnsupportedBlockchain(request.asset.chainValue))
val addresses = allAddresses(request) ?: return Flux.error(SilentException("Unsupported address"))
if (addresses.isEmpty()) {
return Flux.empty()
}
val initial = requestBalances(chain, upstream, addresses)
val addresses = allAddresses(upstream, request).cache()
val following = upstream.getHead().getFlux()
.flatMap { block ->
requestBalances(chain, upstream, addresses)
requestBalances(chain, upstream, Flux.from(addresses), request.includeUtxo)
}
val last = HashMap<Address, BigInteger>()
val result = Flux.merge(initial, following)
val last = HashMap<String, BigInteger>()
val result = following
.filter { curr ->
val prev = last[curr.address]
val updated = prev == null || curr.balance != prev
last[curr.address] = curr.balance
updated
val prev = last[curr.address.address]
//TODO utxo can change without changing balance
val changed = prev == null || curr.balance != prev
if (changed) {
last[curr.address.address] = curr.balance
}
changed
}
return result.map(this@TrackBitcoinAddress::buildResponse)
@@ -149,14 +158,35 @@ class TrackBitcoinAddress(
.setChainValue(address.address.chain.id)
.setCode("BTC"))
.setAddress(Common.SingleAddress.newBuilder().setAddress(address.address.address))
.addAllUtxo(
address.utxo.map { utxo ->
BlockchainOuterClass.Utxo.newBuilder()
.setBalance(utxo.value.toString())
.setIndex(utxo.vout.toLong())
.setTxId(utxo.txid)
.build()
}
)
.build()
}
open class AddressBalance(val address: Address, var balance: BigInteger = BigInteger.ZERO) {
open class AddressBalance(val address: Address, var balance: BigInteger = BigInteger.ZERO, var utxo: List<BalanceUtxo> = emptyList()) {
constructor(chain: Chain, address: String, balance: BigInteger) : this(Address(chain, address), balance)
fun plus(other: AddressBalance) = AddressBalance(address, balance + other.balance)
fun plus(other: AddressBalance) = AddressBalance(address, balance + other.balance, utxo.plus(other.utxo))
}
data class Address(val chain: Chain, val address: String)
open class BalanceUtxo(val txid: String, val vout: Int, val value: Long)
//TODO use bitcoin class for address
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

@@ -0,0 +1,35 @@
/**
* 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 org.bitcoinj.core.Address
import org.slf4j.LoggerFactory
import reactor.core.publisher.Mono
open class AddressActiveCheck(
private val esploraClient: EsploraClient
) {
companion object {
private val log = LoggerFactory.getLogger(AddressActiveCheck::class.java)
}
open fun isActive(address: Address): Mono<Boolean> {
//TODO cache with bloom filter
return esploraClient.getTransactions(address).map { it.isNotEmpty() }
}
}

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,11 @@ 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)
private var addressActiveCheck: AddressActiveCheck? = null
private var xpubAddresses: XpubAddresses? = null
override fun init() {
if (upstreams.size > 0) {
@@ -48,8 +50,12 @@ open class BitcoinMultistream(
super.init()
}
open fun getXpubAddresses(): XpubAddresses? {
return xpubAddresses
}
override fun updateHead(): Head {
head?.let {
head.let {
if (it is Lifecycle) {
it.stop()
}
@@ -81,13 +87,21 @@ open class BitcoinMultistream(
return reader
}
override fun onUpstreamsUpdated() {
super.onUpstreamsUpdated()
esplora = upstreams.find { it.esploraClient != null }?.esploraClient
reader = BitcoinReader(this, this.head, esplora)
addressActiveCheck = esplora?.let { AddressActiveCheck(it) }
xpubAddresses = addressActiveCheck?.let { XpubAddresses(it) }
}
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,120 @@
/**
* 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 = parseResponse(response)
return json.map {
val parsed = Global.objectMapper.readerFor(EsploraUnspent::class.java)
.readValues<EsploraUnspent>(it);
parsed.readAll()
}
}
fun getTransactions(address: Address): Mono<List<Map<String, Any>>> {
val response = httpClient
.get()
.uri("$url/address/$address/txs")
val json = parseResponse(response)
return json.map {
val parsed = Global.objectMapper.readerFor(Map::class.java)
.readValues<Map<String, Any>>(it);
parsed.readAll()
}
}
private fun parseResponse(response: HttpClient.ResponseReceiver<*>): Mono<String> {
return response
.response { header, bytes ->
if (header.status().code() != 200) {
Mono.error(EsploraException("HTTP Code: ${header.status().code()} for ${header.fullPath()}"))
} else {
bytes.aggregate().asByteArray()
}
}
.single()
.map { bytes -> String(bytes) }
}
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,103 @@
/**
* 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 org.bitcoinj.core.Address
import org.bitcoinj.core.ECKey
import org.bitcoinj.core.NetworkParameters
import org.bitcoinj.crypto.ChildNumber
import org.bitcoinj.crypto.DeterministicKey
import org.bitcoinj.crypto.HDKeyDerivation
import org.bitcoinj.params.MainNetParams
import org.bitcoinj.params.TestNet3Params
import org.bitcoinj.script.Script
import org.slf4j.LoggerFactory
import reactor.core.publisher.Flux
import reactor.util.function.Tuples
import java.util.concurrent.atomic.AtomicInteger
open class XpubAddresses(
private val addressActiveCheck: AddressActiveCheck
) {
companion object {
private val log = LoggerFactory.getLogger(XpubAddresses::class.java)
private val MAINNET = MainNetParams()
private val TESTNET = TestNet3Params()
private val INACTIVE_LIMIT = 20
}
open fun allAddresses(xpub: String, start: Int, limit: Int): Flux<Address> {
// versions:
// https://electrum.readthedocs.io/en/latest/xpub_version_bytes.html
// TODO doesn't support SH keys right now. should?
val prefix = xpub.substring(0, 4)
val type: Script.ScriptType
val network: NetworkParameters
when (prefix) {
"xpub" -> {
type = Script.ScriptType.P2PKH
network = MAINNET
}
"zpub" -> {
type = Script.ScriptType.P2WPKH
network = MAINNET
}
"tpub" -> {
type = Script.ScriptType.P2PKH
network = TESTNET
}
"vpub" -> {
type = Script.ScriptType.P2WPKH
network = TESTNET
}
else -> return Flux.error(IllegalArgumentException("Unsupported type: $prefix"))
}
val key: DeterministicKey
try {
key = DeterministicKey.deserializeB58(xpub, network)
} catch (t: Throwable) {
return Flux.error(t)
}
return Flux.range(start, limit)
.map { HDKeyDerivation.deriveChildKey(key, ChildNumber(it, false)) }
.map { Address.fromKey(network, ECKey.fromPublicOnly(it.pubKeyPoint), type) }
}
open fun activeAddresses(xpub: String, start: Int, limit: Int): Flux<Address> {
val lastActive = AtomicInteger(0)
return this.allAddresses(xpub, start, limit)
.zipWith(Flux.range(0, limit))
.takeUntil {
it.t2 - lastActive.get() >= INACTIVE_LIMIT
}
.concatMap { toCheck ->
addressActiveCheck.isActive(toCheck.t1)
.doOnNext { active -> if (active) lastActive.set(toCheck.t2) }
.map { Tuples.of(toCheck.t1, it) }
}
.filter {
it.t2
}
.map {
it.t1
}
}
}

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

@@ -16,6 +16,7 @@
package io.emeraldpay.dshackle.rpc
import com.fasterxml.jackson.databind.ObjectMapper
import com.google.protobuf.ByteString
import io.emeraldpay.api.proto.BlockchainOuterClass
import io.emeraldpay.api.proto.Common
import io.emeraldpay.dshackle.Global
@@ -27,7 +28,12 @@ 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.XpubAddresses
import io.emeraldpay.dshackle.upstream.bitcoin.data.SimpleUnspent
import io.emeraldpay.grpc.Chain
import org.bitcoinj.core.Address
import org.bitcoinj.params.MainNetParams
import org.bitcoinj.params.TestNet3Params
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import reactor.core.publisher.TopicProcessor
@@ -42,89 +48,59 @@ 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.totalUnspent(address, false, unspents)
then:
total.size() == 1
total[0].address.chain == Chain.BITCOIN
total[0].address.address == "1K7xkspJg7DDKNwzXgoRSDCUxiFsRegsSK"
total[0].balance.toString() == "32928461"
total.balance == 100
total.utxo.isEmpty()
}
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("17d1c4adf14b222e652c58d11435fa9ee2ddea000c6f5e20e6b715eb940fc28f", 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.totalUnspent(address, false, unspents)
then:
total.size() == 1
total[0].address.chain == Chain.BITCOIN
total[0].address.address == "1K7xkspJg7DDKNwzXgoRSDCUxiFsRegsSK"
total[0].balance.toString() == "32928461"
total.balance == 223
total.utxo.isEmpty()
}
def "Sum for two addresses"() {
def "Correct sum for few with utxo"() {
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("17d1c4adf14b222e652c58d11435fa9ee2ddea000c6f5e20e6b715eb940fc28f", 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", "35hK24tcLEWcgNA4JxpvbkNkoAcDGqQPsP"], unspents).sort { it.address.address }
def total = track.totalUnspent(address, true, unspents)
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
total.utxo.size() == 2
total.utxo[0].txid == "f14b222e652c58d11435fa9172ddea000c6f5e20e6b715eb940fc28d1c4adeef"
total.utxo[1].txid == "17d1c4adf14b222e652c58d11435fa9ee2ddea000c6f5e20e6b715eb940fc28f"
}
def "One address for single provided"() {
@@ -140,7 +116,7 @@ class TrackBitcoinAddressSpec extends Specification {
)
.build()
when:
def act = track.allAddresses(req)
def act = track.allAddresses(Stub(BitcoinMultistream), req).collectList().block()
then:
act == ["16rCmCmbuWDhPjWTrpQGaU3EPdZF7MTdUk"]
}
@@ -161,20 +137,58 @@ class TrackBitcoinAddressSpec extends Specification {
)
.build()
when:
def act = track.allAddresses(req)
def act = track.allAddresses(Stub(BitcoinMultistream), req).collectList().block()
then:
act == ["16rCmCmbuWDhPjWTrpQGaU3EPdZF7MTdUk", "1K7xkspJg7DDKNwzXgoRSDCUxiFsRegsSK", "3BMqADKWoWHPASsUdHvnUL6E1jpZkMnLZz", "bc1qdthqvt6cllzej7uhdddrltdfsmnt7d0gl5ue5n"]
}
def "Null for no address provided"() {
def "Empty for no address provided"() {
setup:
TrackBitcoinAddress track = new TrackBitcoinAddress(Stub(MultistreamHolder))
def req = BlockchainOuterClass.BalanceRequest.newBuilder()
.build()
when:
def act = track.allAddresses(req)
def act = track.allAddresses(Stub(BitcoinMultistream), req).collectList().block()
then:
act == null
act == []
}
def "Use active for xpub"() {
setup:
TrackBitcoinAddress track = new TrackBitcoinAddress(Stub(MultistreamHolder))
def req = BlockchainOuterClass.BalanceRequest.newBuilder()
.setAddress(
Common.AnyAddress.newBuilder()
.setAddressXpub(
// seed: chimney battle code relief era plug finish video patch dream pumpkin govern destroy fresh color
Common.XpubAddress.newBuilder()
.setXpub(ByteString.copyFromUtf8("zpub6tz4F49K5B4m7r7EyBKYM9K44eGECaQ2AfrCybq1w7ALFatz9856vrXxAPSrteDA4d5sjUPW3ACNq8wB2V3ugXVJxvAPAYPAYHsVm3VAncL"))
.setLimit(25)
)
)
.build()
def xpubAddresses = Mock(XpubAddresses) {
1 * activeAddresses(
"zpub6tz4F49K5B4m7r7EyBKYM9K44eGECaQ2AfrCybq1w7ALFatz9856vrXxAPSrteDA4d5sjUPW3ACNq8wB2V3ugXVJxvAPAYPAYHsVm3VAncL",
0,
25
) >> Flux.fromIterable([
"bc1q25590fu8djhw9lvxxqz8ufjyfwup9h54u8fl6t",
"bc1q3k6e6vawd5l5syu9nlxn2xsch9afgunl8dnz94",
"bc1qu7hd6wycy686kakfps9c093szufjpwnh6rjs9s"
]).map { Address.fromString(MainNetParams.get(), it) }
}
def multistream = Mock(BitcoinMultistream) {
1 * getXpubAddresses() >> xpubAddresses
}
when:
def act = track.allAddresses(multistream, req).collectList().block()
then:
act == [
"bc1q25590fu8djhw9lvxxqz8ufjyfwup9h54u8fl6t",
"bc1q3k6e6vawd5l5syu9nlxn2xsch9afgunl8dnz94",
"bc1qu7hd6wycy686kakfps9c093szufjpwnh6rjs9s"
]
}
def "Build proto for common balance"() {
@@ -221,14 +235,21 @@ class TrackBitcoinAddressSpec extends Specification {
def blocks = TopicProcessor.create()
Head head = Mock(Head) {
1 * getFlux() >> Flux.from(blocks)
1 * getFlux() >> Flux.concat(
Flux.just(
new BlockContainer(0L, BlockId.from(hash1), BigInteger.ZERO, Instant.now(), false, null, null, [])
),
Flux.from(blocks)
)
}
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,134 @@
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.bitcoinj.params.TestNet3Params
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
}
def "get txs"() {
setup:
def responseJson = this.class.getClassLoader().getResourceAsStream("bitcoin/esplora-txs-1.json").text
mockServer.when(
HttpRequest.request()
.withMethod("GET")
.withPath("/address/tb1qyatuwvkfx8thy2ntmtuea6v42vp3zefqvll8kx/txs")
).respond(
HttpResponse.response(responseJson)
)
def client = new EsploraClient(new URI("http://localhost:23001"), null, null)
when:
def act = client.getTransactions(Address.fromString(TestNet3Params.get(), "tb1qyatuwvkfx8thy2ntmtuea6v42vp3zefqvll8kx"))
then:
StepVerifier.create(act)
.expectNextMatches { list ->
def ok = list.size() == 6
if (!ok) println("invalid size")
ok = ok && list[0].txid == "a738f4bd63f785d58acd2e83e8c8c5e84e68dabacac3a1142ee47cb188103aab"
ok = ok && list[1].txid == "4a7066adb2cd8e37f1578d6991f14c12e677e8bc0556992a73a91a26d263f89a"
ok = ok && list[2].txid == "16aa2d98e37e50c4c007a815a3cb8c20026a3df467781a7e97206a730cf4ef01"
ok = ok && list[3].txid == "caaea0ca92343a2d7115c44a0f58cb6574b9349905799854e1b5a6c3b3f33587"
ok = ok && list[4].txid == "a386377a406465423275f51d6dc71a2c245acc55c356a7e851a03b508827bc1e"
ok = ok && list[5].txid == "7944cfcd3d04c58a81aaa7067616f8ac368581a847e03447d5d2917cc75b67d2"
ok
}
.expectComplete()
.verify(Duration.ofSeconds(3))
}
def "get txs when empty"() {
setup:
mockServer.when(
HttpRequest.request()
.withMethod("GET")
.withPath("/address/tb1qyatuwvkfx8thy2ntmtuea6v42vp3zefqvll8kx/txs")
).respond(
HttpResponse.response("[]")
)
def client = new EsploraClient(new URI("http://localhost:23001"), null, null)
when:
def act = client.getTransactions(Address.fromString(TestNet3Params.get(), "tb1qyatuwvkfx8thy2ntmtuea6v42vp3zefqvll8kx"))
then:
StepVerifier.create(act)
.expectNext([])
.expectComplete()
.verify(Duration.ofSeconds(3))
}
}

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

@@ -0,0 +1,175 @@
/**
* 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 org.bitcoinj.core.Address
import org.bitcoinj.params.MainNetParams
import reactor.core.publisher.Mono
import reactor.test.StepVerifier
import spock.lang.Specification
import java.time.Duration
class XpubAddressesSpec extends Specification {
def "list all for mainnet"() {
setup:
XpubAddresses addresses = new XpubAddresses(Stub(AddressActiveCheck))
when:
// seed sustain pyramid victory drum primary silver safe wrestle section leg caught praise spring prepare input
// account 0
def act = addresses.allAddresses(
"zpub6tsHyQGj1UmDcw9UavB31HSChH9rqMi8Qje5KPbnczzhFSZykmm3afivpVytxWubaabDih6AbGGhioiTCg5PssYXuVGiZ1vTVaMYvvQvvmz",
0,
4
).collectList().block()
then:
act.size() == 4
act[0].toString() == "bc1qd6p79j20w2zy5zagptf5ksjkdmhx4d5sykrz0e"
act[1].toString() == "bc1q54um5vzdjuwt6qvzvk2y5em72jedpktrvr24a3"
act[2].toString() == "bc1q3yl03ugy6l5k3te5x340m70hyeqjzmmvksnuc8"
act[3].toString() == "bc1qp0cgpkxl52r82ev9k2wpdzzk4m4nflwurj5yr8"
}
def "list all for testnet"() {
setup:
XpubAddresses addresses = new XpubAddresses(Stub(AddressActiveCheck))
when:
// seed sustain pyramid victory drum primary silver safe wrestle section leg caught praise spring prepare input
// account 1
def act = addresses.allAddresses(
"vpub5aEsY5bNGvjkHPjdMiz8FbdoiT81kmF3JwuJ1LRdoFb7DxNh1qAHQCmMeDnr6RG2EcCwzGohem3oESxZGa6YWLPW79ryCyMrdYj54uUzNNq",
0,
4
).collectList().block()
then:
act.size() == 4
act[0].toString() == "tb1q2phmcxl4tflgcrnm00jvrkh4a8n876vkjcv97m"
act[1].toString() == "tb1qnhn0psv0uqtmg64tm258kqu0thurjvutcc80xs"
act[2].toString() == "tb1qg62jwv77ul329pe4wcchr83zz02egwfh6syqsr"
act[3].toString() == "tb1q8yg5qknrl5wmc98lw8dyfgrzamzaq8c9l0g44d"
}
def "list starting from some"() {
setup:
XpubAddresses addresses = new XpubAddresses(Stub(AddressActiveCheck))
when:
// seed sustain pyramid victory drum primary silver safe wrestle section leg caught praise spring prepare input
// account 2
def act = addresses.allAddresses(
"zpub6tyKtxsQL16XpzxwFJSrVTxpKF4z8GL2WTqvdErC2iQBSEAzLqJNc8h6hg4EHMSPEavFDneR3JQjWjbZL7vtQrXMwN9bD4c9K9s1Z55aZfB",
10,
4
).collectList().block()
then:
act.size() == 4
act[0].toString() == "bc1q3dzm92mnqvkyadwl3x5cqdvxaqa5h9c37qdlgs"
act[1].toString() == "bc1q3kqug4cx95a02yhwn6geelftmw3zklrgmhjll8"
act[2].toString() == "bc1q6znmcw8zzjv3asdylgkmt0k0xvhzl9q5awh0sq"
act[3].toString() == "bc1q3fzlxvw803r8j44culrt7jz7q5y7zakushujmq"
}
def "list when only fist is active"() {
setup:
AddressActiveCheck check = Mock(AddressActiveCheck) {
1 * isActive(Address.fromString(MainNetParams.get(), "bc1qaexx257l7sgm62szw2ulj6n2v99t5ph9ekkul3")) >> Mono.just(true)
20 * isActive(_) >> Mono.just(false)
}
XpubAddresses addresses = new XpubAddresses(check)
// seed sustain pyramid victory drum primary silver safe wrestle section leg caught praise spring prepare input
// account 2
when:
def act = addresses.activeAddresses(
"zpub6tyKtxsQL16XpzxwFJSrVTxpKF4z8GL2WTqvdErC2iQBSEAzLqJNc8h6hg4EHMSPEavFDneR3JQjWjbZL7vtQrXMwN9bD4c9K9s1Z55aZfB",
0,
50
).map { it.toString() }
then:
StepVerifier.create(act)
.expectNext("bc1qaexx257l7sgm62szw2ulj6n2v99t5ph9ekkul3")
.expectComplete()
.verify(Duration.ofSeconds(1))
}
def "list when only 3rd is active"() {
setup:
AddressActiveCheck check = Mock(AddressActiveCheck) {
1 * isActive(Address.fromString(MainNetParams.get(), "bc1qah84zz7aavf3f5eyx5f29809y6xugqyphq0wtz")) >> Mono.just(true)
// 2 times before, 20 times after
22 * isActive(_) >> Mono.just(false)
}
XpubAddresses addresses = new XpubAddresses(check)
// seed sustain pyramid victory drum primary silver safe wrestle section leg caught praise spring prepare input
// account 2
when:
def act = addresses.activeAddresses(
"zpub6tyKtxsQL16XpzxwFJSrVTxpKF4z8GL2WTqvdErC2iQBSEAzLqJNc8h6hg4EHMSPEavFDneR3JQjWjbZL7vtQrXMwN9bD4c9K9s1Z55aZfB",
0,
50
).map { it.toString() }
then:
StepVerifier.create(act)
.expectNext("bc1qah84zz7aavf3f5eyx5f29809y6xugqyphq0wtz")
.expectComplete()
.verify(Duration.ofSeconds(1))
}
def "list when only 22 is active"() {
setup:
AddressActiveCheck check = Mock(AddressActiveCheck) {
// 2
1 * isActive(Address.fromString(MainNetParams.get(), "bc1qah84zz7aavf3f5eyx5f29809y6xugqyphq0wtz")) >> Mono.just(true)
// 11
1 * isActive(Address.fromString(MainNetParams.get(), "bc1q3kqug4cx95a02yhwn6geelftmw3zklrgmhjll8")) >> Mono.just(true)
// 22
1 * isActive(Address.fromString(MainNetParams.get(), "bc1qf7m2rrmrksj34vhgxmm04y43dlj7c5f58f8ku6")) >> Mono.just(true)
// 0..1 = 2
// + 3..10 = 8
// + 12..21 = 10
// + 20
40 * isActive(_) >> Mono.just(false)
}
XpubAddresses addresses = new XpubAddresses(check)
// seed sustain pyramid victory drum primary silver safe wrestle section leg caught praise spring prepare input
// account 2
when:
def act = addresses.activeAddresses(
"zpub6tyKtxsQL16XpzxwFJSrVTxpKF4z8GL2WTqvdErC2iQBSEAzLqJNc8h6hg4EHMSPEavFDneR3JQjWjbZL7vtQrXMwN9bD4c9K9s1Z55aZfB",
0,
100
).map { it.toString() }
then:
StepVerifier.create(act)
.expectNext("bc1qah84zz7aavf3f5eyx5f29809y6xugqyphq0wtz")
.expectNext("bc1q3kqug4cx95a02yhwn6geelftmw3zklrgmhjll8")
.expectNext("bc1qf7m2rrmrksj34vhgxmm04y43dlj7c5f58f8ku6")
.expectComplete()
.verify(Duration.ofSeconds(1))
}
}

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,287 @@
[
{
"txid": "a738f4bd63f785d58acd2e83e8c8c5e84e68dabacac3a1142ee47cb188103aab",
"version": 1,
"locktime": 0,
"vin": [
{
"txid": "16aa2d98e37e50c4c007a815a3cb8c20026a3df467781a7e97206a730cf4ef01",
"vout": 1,
"prevout": {
"scriptpubkey": "00142757c732c931d7722a6bdaf99ee9955303116520",
"scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 2757c732c931d7722a6bdaf99ee9955303116520",
"scriptpubkey_type": "v0_p2wpkh",
"scriptpubkey_address": "tb1qyatuwvkfx8thy2ntmtuea6v42vp3zefqvll8kx",
"value": 120000
},
"scriptsig": "",
"scriptsig_asm": "",
"witness": [
"304502210084f12093ae826635c6bce4915e927dd7a2285b37d0b51148c6d6177ce72e459d02201bf0b3d7cde090d2dbc4766b7a8b3d947e1bdb02dcf03ce894ff1a105278eb8901",
"03a3aa29d96671671b065b35de511c03ea5592eafb5de7a07542633af2d42f49ea"
],
"is_coinbase": false,
"sequence": 4294967293
}
],
"vout": [
{
"scriptpubkey": "00142757c732c931d7722a6bdaf99ee9955303116520",
"scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 2757c732c931d7722a6bdaf99ee9955303116520",
"scriptpubkey_type": "v0_p2wpkh",
"scriptpubkey_address": "tb1qyatuwvkfx8thy2ntmtuea6v42vp3zefqvll8kx",
"value": 119568
}
],
"size": 192,
"weight": 438,
"fee": 432,
"status": {
"confirmed": true,
"block_height": 1831865,
"block_hash": "000000000000014e643687acc0a975f9aa7319f1e9bb428e0f1d618ed6aba370",
"block_time": 1599447368
}
},
{
"txid": "4a7066adb2cd8e37f1578d6991f14c12e677e8bc0556992a73a91a26d263f89a",
"version": 1,
"locktime": 0,
"vin": [
{
"txid": "a386377a406465423275f51d6dc71a2c245acc55c356a7e851a03b508827bc1e",
"vout": 1,
"prevout": {
"scriptpubkey": "00142757c732c931d7722a6bdaf99ee9955303116520",
"scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 2757c732c931d7722a6bdaf99ee9955303116520",
"scriptpubkey_type": "v0_p2wpkh",
"scriptpubkey_address": "tb1qyatuwvkfx8thy2ntmtuea6v42vp3zefqvll8kx",
"value": 120000
},
"scriptsig": "",
"scriptsig_asm": "",
"witness": [
"3045022100c5af6d33efc9a564c0e955160e0b8def089cc6c933ddf08967eac25d4ddfc09a02205ed59dcc068251dee669ca19425c0c83208d1b1c1f5b7147f4f0b7cb2d15a87201",
"03a3aa29d96671671b065b35de511c03ea5592eafb5de7a07542633af2d42f49ea"
],
"is_coinbase": false,
"sequence": 4294967295
}
],
"vout": [
{
"scriptpubkey": "00142757c732c931d7722a6bdaf99ee9955303116520",
"scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 2757c732c931d7722a6bdaf99ee9955303116520",
"scriptpubkey_type": "v0_p2wpkh",
"scriptpubkey_address": "tb1qyatuwvkfx8thy2ntmtuea6v42vp3zefqvll8kx",
"value": 119568
}
],
"size": 192,
"weight": 438,
"fee": 432,
"status": {
"confirmed": true,
"block_height": 1831846,
"block_hash": "0000000000000104a5b1241385c2cfb728128ce692ab8bd8970a0372761f8887",
"block_time": 1599439188
}
},
{
"txid": "16aa2d98e37e50c4c007a815a3cb8c20026a3df467781a7e97206a730cf4ef01",
"version": 2,
"locktime": 1831845,
"vin": [
{
"txid": "a386377a406465423275f51d6dc71a2c245acc55c356a7e851a03b508827bc1e",
"vout": 0,
"prevout": {
"scriptpubkey": "00140fc773ca476039acb48162e26190b3b93cda6762",
"scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 0fc773ca476039acb48162e26190b3b93cda6762",
"scriptpubkey_type": "v0_p2wpkh",
"scriptpubkey_address": "tb1qplrh8jj8vqu6edypvt3xry9nhy7d5emzyj3a3h",
"value": 1619316
},
"scriptsig": "",
"scriptsig_asm": "",
"witness": [
"3044022067011a2a7b6823146ff8291eed673e2e52703e07b8b99cf78b0fbb968e5ca980022018244b0584f4851ec9d09296007898c00f05afdcc6c2d8eafb48e95923be609801",
"031117740bfada16dbe932219d5d5ddbb0a829d83ccbfe5eb7734d8646f9a63479"
],
"is_coinbase": false,
"sequence": 4294967293
}
],
"vout": [
{
"scriptpubkey": "0014a871aa972c4ef9255a610b3b849598991ba9df1c",
"scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 a871aa972c4ef9255a610b3b849598991ba9df1c",
"scriptpubkey_type": "v0_p2wpkh",
"scriptpubkey_address": "tb1q4pc649evfmuj2knppvacf9vcnyd6nhculm5lsk",
"value": 1499175
},
{
"scriptpubkey": "00142757c732c931d7722a6bdaf99ee9955303116520",
"scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 2757c732c931d7722a6bdaf99ee9955303116520",
"scriptpubkey_type": "v0_p2wpkh",
"scriptpubkey_address": "tb1qyatuwvkfx8thy2ntmtuea6v42vp3zefqvll8kx",
"value": 120000
}
],
"size": 222,
"weight": 561,
"fee": 141,
"status": {
"confirmed": true,
"block_height": 1831846,
"block_hash": "0000000000000104a5b1241385c2cfb728128ce692ab8bd8970a0372761f8887",
"block_time": 1599439188
}
},
{
"txid": "caaea0ca92343a2d7115c44a0f58cb6574b9349905799854e1b5a6c3b3f33587",
"version": 1,
"locktime": 0,
"vin": [
{
"txid": "7944cfcd3d04c58a81aaa7067616f8ac368581a847e03447d5d2917cc75b67d2",
"vout": 1,
"prevout": {
"scriptpubkey": "00142757c732c931d7722a6bdaf99ee9955303116520",
"scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 2757c732c931d7722a6bdaf99ee9955303116520",
"scriptpubkey_type": "v0_p2wpkh",
"scriptpubkey_address": "tb1qyatuwvkfx8thy2ntmtuea6v42vp3zefqvll8kx",
"value": 120000
},
"scriptsig": "",
"scriptsig_asm": "",
"witness": [
"304402200fe43b74f1326e51d5c718b5c0a9319c0241de9329e7d39e613218f66bf72f1202205918d5b29b7d6887a4f61fe3f7c330beb306e195b49e7149f5de1554f8aaa10501",
"03a3aa29d96671671b065b35de511c03ea5592eafb5de7a07542633af2d42f49ea"
],
"is_coinbase": false,
"sequence": 4294967294
}
],
"vout": [
{
"scriptpubkey": "00146d2112c30c7ef1bf66a4ff2e1bfa0304c3b8f118",
"scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 6d2112c30c7ef1bf66a4ff2e1bfa0304c3b8f118",
"scriptpubkey_type": "v0_p2wpkh",
"scriptpubkey_address": "tb1qd5s39scv0mcm7e4yluhph7srqnpm3ugcx2mc5d",
"value": 119568
}
],
"size": 191,
"weight": 437,
"fee": 432,
"status": {
"confirmed": true,
"block_height": 1831820,
"block_hash": "0000000009f11ea4cd3a3393d1cc355a63ea37a5a2cbf983e34cbb7f37590eb2",
"block_time": 1599426488
}
},
{
"txid": "a386377a406465423275f51d6dc71a2c245acc55c356a7e851a03b508827bc1e",
"version": 2,
"locktime": 1831819,
"vin": [
{
"txid": "7944cfcd3d04c58a81aaa7067616f8ac368581a847e03447d5d2917cc75b67d2",
"vout": 0,
"prevout": {
"scriptpubkey": "0014da239eb3f9727ccbfe73823e427fb7289e7c192a",
"scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 da239eb3f9727ccbfe73823e427fb7289e7c192a",
"scriptpubkey_type": "v0_p2wpkh",
"scriptpubkey_address": "tb1qmg3eavlewf7vhlnnsglyylah9z08cxf2a0g2nv",
"value": 1739457
},
"scriptsig": "",
"scriptsig_asm": "",
"witness": [
"3044022039020c3e01db872751e8c55f584aea2820b4e7c04c5a859ac785983aa2aaf612022012e8b0f6b052cecbcbe452dc0c8b6093f0520b355d206476c934ee879e892cfe01",
"023a49d61f3162fbba038809868a3c943de0549bae2f197d78ea8670452595cece"
],
"is_coinbase": false,
"sequence": 4294967293
}
],
"vout": [
{
"scriptpubkey": "00140fc773ca476039acb48162e26190b3b93cda6762",
"scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 0fc773ca476039acb48162e26190b3b93cda6762",
"scriptpubkey_type": "v0_p2wpkh",
"scriptpubkey_address": "tb1qplrh8jj8vqu6edypvt3xry9nhy7d5emzyj3a3h",
"value": 1619316
},
{
"scriptpubkey": "00142757c732c931d7722a6bdaf99ee9955303116520",
"scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 2757c732c931d7722a6bdaf99ee9955303116520",
"scriptpubkey_type": "v0_p2wpkh",
"scriptpubkey_address": "tb1qyatuwvkfx8thy2ntmtuea6v42vp3zefqvll8kx",
"value": 120000
}
],
"size": 222,
"weight": 561,
"fee": 141,
"status": {
"confirmed": true,
"block_height": 1831820,
"block_hash": "0000000009f11ea4cd3a3393d1cc355a63ea37a5a2cbf983e34cbb7f37590eb2",
"block_time": 1599426488
}
},
{
"txid": "7944cfcd3d04c58a81aaa7067616f8ac368581a847e03447d5d2917cc75b67d2",
"version": 2,
"locktime": 1831428,
"vin": [
{
"txid": "86f3a772c109695ae67fe15e30df8929d2b71a2beef86ac51df406150d991a4b",
"vout": 0,
"prevout": {
"scriptpubkey": "00141d7aa33e35e539b16b11167627e818fd436ea907",
"scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 1d7aa33e35e539b16b11167627e818fd436ea907",
"scriptpubkey_type": "v0_p2wpkh",
"scriptpubkey_address": "tb1qr4a2x034u5umz6c3zemz06qcl4pka2g8cc2qaf",
"value": 1859598
},
"scriptsig": "",
"scriptsig_asm": "",
"witness": [
"3044022020fa758e75fa4f67665b14cc50f871883a80c1c1f4f45c403e7f8c5631b8734002200ddde3f72dbe88975365777c15da85c44eaa6ba53384194b351709f015f4bf2001",
"02f0db65b4318ee91f58d1f7da51fb16c46e3a01f6f3bd07af1b8be63e525e3e45"
],
"is_coinbase": false,
"sequence": 4294967293
}
],
"vout": [
{
"scriptpubkey": "0014da239eb3f9727ccbfe73823e427fb7289e7c192a",
"scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 da239eb3f9727ccbfe73823e427fb7289e7c192a",
"scriptpubkey_type": "v0_p2wpkh",
"scriptpubkey_address": "tb1qmg3eavlewf7vhlnnsglyylah9z08cxf2a0g2nv",
"value": 1739457
},
{
"scriptpubkey": "00142757c732c931d7722a6bdaf99ee9955303116520",
"scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 2757c732c931d7722a6bdaf99ee9955303116520",
"scriptpubkey_type": "v0_p2wpkh",
"scriptpubkey_address": "tb1qyatuwvkfx8thy2ntmtuea6v42vp3zefqvll8kx",
"value": 120000
}
],
"size": 222,
"weight": 561,
"fee": 141,
"status": {
"confirmed": true,
"block_height": 1831429,
"block_hash": "00000000000000235978c8a27a20881376bb94b831d2072d80500d2350115be1",
"block_time": 1599254339
}
}
]

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: