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 - [ ] External logging
- [ ] Prometheus monitoring - [ ] Prometheus monitoring
- [ ] Subscription to bitcoind notification over gRPC (instead of ZeroMQ) - [ ] Subscription to bitcoind notification over gRPC (instead of ZeroMQ)
- [ ] BIP-32 Pubkey
- [ ] Lightweight sidecar node connector - [ ] Lightweight sidecar node connector
- [ ] Configurable upstream roles - [ ] Configurable upstream roles

View File

@@ -57,7 +57,7 @@ configurations {
} }
dependencies { 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-protobuf:${grpcVersion}"
implementation "io.grpc:grpc-stub:${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.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.module.SimpleModule 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.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.text.SimpleDateFormat
import java.util.* import java.util.*
@@ -36,6 +38,9 @@ class Global {
val module = SimpleModule("EmeraldDshackle", Version(1, 0, 0, null, null, null)) val module = SimpleModule("EmeraldDshackle", Version(1, 0, 0, null, null, null))
module.addSerializer(JsonRpcResponse::class.java, JsonRpcResponse.ResponseJsonSerializer()) module.addSerializer(JsonRpcResponse::class.java, JsonRpcResponse.ResponseJsonSerializer())
module.addDeserializer(EsploraUnspent::class.java, EsploraUnspentDeserializer())
module.addDeserializer(RpcUnspent::class.java, RpcUnspentDeserializer())
val objectMapper = ObjectMapper() val objectMapper = ObjectMapper()
objectMapper.registerModule(module) objectMapper.registerModule(module)
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)

View File

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

View File

@@ -132,6 +132,14 @@ class UpstreamsConfigReader(
http.tls = authConfigReader.readClientTls(node) 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 { } else {
log.error("Upstream at #0 has invalid configuration") 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.SilentException
import io.emeraldpay.dshackle.upstream.MultistreamHolder import io.emeraldpay.dshackle.upstream.MultistreamHolder
import io.emeraldpay.dshackle.upstream.bitcoin.BitcoinMultistream import io.emeraldpay.dshackle.upstream.bitcoin.BitcoinMultistream
import io.emeraldpay.dshackle.upstream.bitcoin.data.SimpleUnspent
import io.emeraldpay.grpc.Chain import io.emeraldpay.grpc.Chain
import org.bitcoinj.params.MainNetParams
import org.bitcoinj.params.TestNet3Params
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import reactor.core.publisher.Flux import reactor.core.publisher.Flux
import reactor.util.function.Tuples import reactor.core.publisher.Mono
import java.math.BigDecimal
import java.math.BigInteger import java.math.BigInteger
import java.util.*
import kotlin.collections.HashMap import kotlin.collections.HashMap
@Service @Service
@@ -46,97 +47,105 @@ class TrackBitcoinAddress(
&& BlockchainType.fromBlockchain(chain) == BlockchainType.BITCOIN && multistreamHolder.isAvailable(chain) && 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()) { if (!request.hasAddress()) {
return null return Flux.empty()
} }
return when { 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() -> { request.address.hasAddressSingle() -> {
listOf(request.address.addressSingle.address) Flux.just(request.address.addressSingle.address)
} }
request.address.hasAddressMulti() -> { request.address.hasAddressMulti() -> {
request.address.addressMulti.addressesList Flux.fromIterable(
.map { addr -> addr.address } request.address.addressMulti.addressesList
.sorted() .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> { fun requestBalances(chain: Chain, api: BitcoinMultistream, addresses: Flux<String>, includeUtxo: Boolean): Flux<AddressBalance> {
return api.getReader().listUnspent() return addresses
.flatMapMany { unspents -> .map { Address(chain, it) }
val result = getTotal(chain, addresses, unspents) .flatMap { address ->
Flux.fromIterable(result) 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> { override fun getBalance(request: BlockchainOuterClass.BalanceRequest): Flux<BlockchainOuterClass.AddressBalance> {
val chain = Chain.byId(request.asset.chainValue) val chain = Chain.byId(request.asset.chainValue)
val upstream = multistreamHolder.getUpstream(chain)?.cast(BitcoinMultistream::class.java) val upstream = multistreamHolder.getUpstream(chain)?.cast(BitcoinMultistream::class.java)
?: return Flux.error(SilentException.UnsupportedBlockchain(request.asset.chainValue)) ?: return Flux.error(SilentException.UnsupportedBlockchain(request.asset.chainValue))
val addresses = allAddresses(request) ?: return Flux.error(SilentException("Unsupported address")) val addresses = allAddresses(upstream, request) ?: return Flux.error(SilentException("Unsupported address"))
if (addresses.isEmpty()) { return requestBalances(chain, upstream, addresses, request.includeUtxo)
return Flux.empty()
}
return requestBalances(chain, upstream, addresses)
.map(this@TrackBitcoinAddress::buildResponse) .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> { override fun subscribe(request: BlockchainOuterClass.BalanceRequest): Flux<BlockchainOuterClass.AddressBalance> {
val chain = Chain.byId(request.asset.chainValue) val chain = Chain.byId(request.asset.chainValue)
val upstream = multistreamHolder.getUpstream(chain)?.cast(BitcoinMultistream::class.java) val upstream = multistreamHolder.getUpstream(chain)?.cast(BitcoinMultistream::class.java)
?: return Flux.error(SilentException.UnsupportedBlockchain(request.asset.chainValue)) ?: return Flux.error(SilentException.UnsupportedBlockchain(request.asset.chainValue))
val addresses = allAddresses(request) ?: return Flux.error(SilentException("Unsupported address")) val addresses = allAddresses(upstream, request).cache()
if (addresses.isEmpty()) {
return Flux.empty()
}
val initial = requestBalances(chain, upstream, addresses)
val following = upstream.getHead().getFlux() val following = upstream.getHead().getFlux()
.flatMap { block -> .flatMap { block ->
requestBalances(chain, upstream, addresses) requestBalances(chain, upstream, Flux.from(addresses), request.includeUtxo)
} }
val last = HashMap<Address, BigInteger>() val last = HashMap<String, BigInteger>()
val result = Flux.merge(initial, following) val result = following
.filter { curr -> .filter { curr ->
val prev = last[curr.address] val prev = last[curr.address.address]
val updated = prev == null || curr.balance != prev //TODO utxo can change without changing balance
last[curr.address] = curr.balance val changed = prev == null || curr.balance != prev
updated if (changed) {
last[curr.address.address] = curr.balance
}
changed
} }
return result.map(this@TrackBitcoinAddress::buildResponse) return result.map(this@TrackBitcoinAddress::buildResponse)
@@ -149,14 +158,35 @@ class TrackBitcoinAddress(
.setChainValue(address.address.chain.id) .setChainValue(address.address.chain.id)
.setCode("BTC")) .setCode("BTC"))
.setAddress(Common.SingleAddress.newBuilder().setAddress(address.address.address)) .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() .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) 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.reader.Reader
import io.emeraldpay.dshackle.upstream.CurrentMultistreamHolder import io.emeraldpay.dshackle.upstream.CurrentMultistreamHolder
import io.emeraldpay.dshackle.upstream.bitcoin.BitcoinRpcUpstream 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.CallMethods
import io.emeraldpay.dshackle.upstream.calls.ManagedCallMethods import io.emeraldpay.dshackle.upstream.calls.ManagedCallMethods
import io.emeraldpay.dshackle.upstream.ethereum.EthereumRpcUpstream import io.emeraldpay.dshackle.upstream.ethereum.EthereumRpcUpstream
@@ -142,12 +143,21 @@ open class ConfiguredUpstreams(
return 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 methods = buildMethods(config, chain)
val upstream = BitcoinRpcUpstream(config.id val upstream = BitcoinRpcUpstream(config.id
?: "bitcoin-${seq.getAndIncrement()}", chain, directApi, ?: "bitcoin-${seq.getAndIncrement()}", chain, directApi,
options, config.role, options, config.role,
QuorumForLabels.QuorumItem(1, config.labels), QuorumForLabels.QuorumItem(1, config.labels),
methods) methods, esplora)
upstream.start() upstream.start()
currentUpstreams.update(UpstreamChange(chain, upstream, UpstreamChange.ChangeType.ADDED)) 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) { fun addUpstream(upstream: Upstream) {
upstreams.add(upstream) upstreams.add(upstream)
setHead(updateHead())
onUpstreamsUpdated() onUpstreamsUpdated()
setHead(updateHead())
} }
fun removeUpstream(id: String) { fun removeUpstream(id: String) {
if (upstreams.removeIf { it.getId() == id }) { if (upstreams.removeIf { it.getId() == id }) {
setHead(updateHead())
onUpstreamsUpdated() onUpstreamsUpdated()
setHead(updateHead())
} }
} }
@@ -103,7 +103,7 @@ abstract class Multistream(
/** /**
* Finds an API that executed directly on a remote. * 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) val apis = getApiSource(matcher)
apis.request(1) apis.request(1)
return Mono.from(apis) return Mono.from(apis)
@@ -120,7 +120,7 @@ abstract class Multistream(
throw NotImplementedError("Immediate direct API is not implemented for Aggregated Upstream") throw NotImplementedError("Immediate direct API is not implemented for Aggregated Upstream")
} }
fun onUpstreamsUpdated() { open fun onUpstreamsUpdated() {
reconfigLock.withLock { reconfigLock.withLock {
getAll().map { it.getMethods() }.let { getAll().map { it.getMethods() }.let {
//TODO made list of uniq instances, and then if only one, just use it directly //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 package io.emeraldpay.dshackle.upstream.bitcoin
import com.fasterxml.jackson.databind.ObjectMapper
import io.emeraldpay.dshackle.cache.Caches import io.emeraldpay.dshackle.cache.Caches
import io.emeraldpay.dshackle.config.UpstreamsConfig import io.emeraldpay.dshackle.config.UpstreamsConfig
import io.emeraldpay.dshackle.reader.EmptyReader import io.emeraldpay.dshackle.reader.EmptyReader
@@ -38,8 +37,11 @@ open class BitcoinMultistream(
private val log = LoggerFactory.getLogger(BitcoinMultistream::class.java) private val log = LoggerFactory.getLogger(BitcoinMultistream::class.java)
} }
private var head: Head? = null private var head: Head = EmptyHead()
private var reader = BitcoinReader(this, 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() { override fun init() {
if (upstreams.size > 0) { if (upstreams.size > 0) {
@@ -48,8 +50,12 @@ open class BitcoinMultistream(
super.init() super.init()
} }
open fun getXpubAddresses(): XpubAddresses? {
return xpubAddresses
}
override fun updateHead(): Head { override fun updateHead(): Head {
head?.let { head.let {
if (it is Lifecycle) { if (it is Lifecycle) {
it.stop() it.stop()
} }
@@ -81,13 +87,21 @@ open class BitcoinMultistream(
return reader 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) { override fun setHead(head: Head) {
this.head = head this.head = head
reader = BitcoinReader(this, head) reader = BitcoinReader(this, head, esplora)
} }
override fun getHead(): Head { override fun getHead(): Head {
return head!! return head
} }
override fun getLabels(): Collection<UpstreamsConfig.Labels> { 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.Global
import io.emeraldpay.dshackle.upstream.Head import io.emeraldpay.dshackle.upstream.Head
import io.emeraldpay.dshackle.upstream.Selector 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.JsonRpcRequest
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcResponse import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcResponse
import org.bitcoinj.core.Address
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.context.Lifecycle import org.springframework.context.Lifecycle
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
@@ -28,7 +30,8 @@ import reactor.kotlin.core.publisher.cast
open class BitcoinReader( open class BitcoinReader(
private val upstreams: BitcoinMultistream, private val upstreams: BitcoinMultistream,
head: Head head: Head,
esploraClient: EsploraClient?
) : Lifecycle { ) : Lifecycle {
companion object { companion object {
@@ -38,6 +41,12 @@ open class BitcoinReader(
private val objectMapper: ObjectMapper = Global.objectMapper private val objectMapper: ObjectMapper = Global.objectMapper
private val mempool = CachingMempoolData(upstreams, head) private val mempool = CachingMempoolData(upstreams, head)
private val unspentReader: UnspentReader = if (esploraClient != null) {
EsploraUnspentReader(esploraClient, head)
} else {
RpcUnspentReader(upstreams)
}
open fun getMempool(): CachingMempoolData { open fun getMempool(): CachingMempoolData {
return mempool return mempool
} }
@@ -50,8 +59,8 @@ open class BitcoinReader(
return castedRead(JsonRpcRequest("getrawtransaction", listOf(txid, true)), Map::class.java).cast() return castedRead(JsonRpcRequest("getrawtransaction", listOf(txid, true)), Map::class.java).cast()
} }
open fun listUnspent(): Mono<List<String>> { open fun listUnspent(address: Address): Mono<List<SimpleUnspent>> {
return castedRead(JsonRpcRequest("listunspent", emptyList()), List::class.java).cast() return unspentReader.read(address)
} }
override fun isRunning(): Boolean { override fun isRunning(): Boolean {

View File

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

View File

@@ -29,7 +29,8 @@ abstract class BitcoinUpstream(
options: UpstreamsConfig.Options, options: UpstreamsConfig.Options,
role: UpstreamsConfig.UpstreamRole, role: UpstreamsConfig.UpstreamRole,
callMethods: CallMethods, callMethods: CallMethods,
node: QuorumForLabels.QuorumItem node: QuorumForLabels.QuorumItem,
val esploraClient: EsploraClient? = null
) : DefaultUpstream(id, options, role, callMethods, node) { ) : DefaultUpstream(id, options, role, callMethods, node) {
constructor(id: String, 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"() { def "Parse ds config"() {
setup: setup:
def config = this.class.getClassLoader().getResourceAsStream("upstreams-ds.yaml") def config = this.class.getClassLoader().getResourceAsStream("upstreams-ds.yaml")

View File

@@ -16,6 +16,7 @@
package io.emeraldpay.dshackle.rpc package io.emeraldpay.dshackle.rpc
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import com.google.protobuf.ByteString
import io.emeraldpay.api.proto.BlockchainOuterClass import io.emeraldpay.api.proto.BlockchainOuterClass
import io.emeraldpay.api.proto.Common import io.emeraldpay.api.proto.Common
import io.emeraldpay.dshackle.Global 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.MultistreamHolder
import io.emeraldpay.dshackle.upstream.bitcoin.BitcoinMultistream import io.emeraldpay.dshackle.upstream.bitcoin.BitcoinMultistream
import io.emeraldpay.dshackle.upstream.bitcoin.BitcoinReader 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 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.Flux
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
import reactor.core.publisher.TopicProcessor import reactor.core.publisher.TopicProcessor
@@ -42,89 +48,59 @@ class TrackBitcoinAddressSpec extends Specification {
String hash1 = "0xa0e65cbc1b52a8ca60562112c6060552d882f16f34a9dba2ccdc05c0a6a27c22" String hash1 = "0xa0e65cbc1b52a8ca60562112c6060552d882f16f34a9dba2ccdc05c0a6a27c22"
ObjectMapper objectMapper = Global.objectMapper ObjectMapper objectMapper = Global.objectMapper
def "Correct sum from multiple"() { def "Correct sum for single"() {
setup: setup:
def json = this.class.getClassLoader().getResourceAsStream("bitcoin/unspent-one-addr.json") def unspents = [
def unspents = objectMapper.readValue(json, List) new SimpleUnspent("f14b222e652c58d11435fa9172ddea000c6f5e20e6b715eb940fc28d1c4adeef", 0, 100L, 123L)
]
TrackBitcoinAddress track = new TrackBitcoinAddress(Stub(MultistreamHolder)) TrackBitcoinAddress track = new TrackBitcoinAddress(Stub(MultistreamHolder))
def address = new TrackBitcoinAddress.Address(
Chain.BITCOIN, "1K7xkspJg7DDKNwzXgoRSDCUxiFsRegsSK"
)
when: when:
def total = track.getTotal(Chain.BITCOIN, ["1K7xkspJg7DDKNwzXgoRSDCUxiFsRegsSK"], unspents) def total = track.totalUnspent(address, false, unspents)
then: then:
total.size() == 1 total.balance == 100
total[0].address.chain == Chain.BITCOIN total.utxo.isEmpty()
total[0].address.address == "1K7xkspJg7DDKNwzXgoRSDCUxiFsRegsSK"
total[0].balance.toString() == "32928461"
} }
def "Correct sum when other addresses"() { def "Correct sum for few"() {
setup: setup:
def json = this.class.getClassLoader().getResourceAsStream("bitcoin/unspent-two-addr.json") def unspents = [
def unspents = objectMapper.readValue(json, List) new SimpleUnspent("f14b222e652c58d11435fa9172ddea000c6f5e20e6b715eb940fc28d1c4adeef", 0, 100L, 123L),
new SimpleUnspent("17d1c4adf14b222e652c58d11435fa9ee2ddea000c6f5e20e6b715eb940fc28f", 0, 123L, 123L),
]
TrackBitcoinAddress track = new TrackBitcoinAddress(Stub(MultistreamHolder)) TrackBitcoinAddress track = new TrackBitcoinAddress(Stub(MultistreamHolder))
def address = new TrackBitcoinAddress.Address(
Chain.BITCOIN, "1K7xkspJg7DDKNwzXgoRSDCUxiFsRegsSK"
)
when: when:
def total = track.getTotal(Chain.BITCOIN, ["1K7xkspJg7DDKNwzXgoRSDCUxiFsRegsSK"], unspents) def total = track.totalUnspent(address, false, unspents)
then: then:
total.size() == 1 total.balance == 223
total[0].address.chain == Chain.BITCOIN total.utxo.isEmpty()
total[0].address.address == "1K7xkspJg7DDKNwzXgoRSDCUxiFsRegsSK"
total[0].balance.toString() == "32928461"
} }
def "Sum for two addresses"() { def "Correct sum for few with utxo"() {
setup: setup:
def json = this.class.getClassLoader().getResourceAsStream("bitcoin/unspent-two-addr.json") def unspents = [
def unspents = objectMapper.readValue(json, List) new SimpleUnspent("f14b222e652c58d11435fa9172ddea000c6f5e20e6b715eb940fc28d1c4adeef", 0, 100L, 123L),
new SimpleUnspent("17d1c4adf14b222e652c58d11435fa9ee2ddea000c6f5e20e6b715eb940fc28f", 0, 123L, 123L),
]
TrackBitcoinAddress track = new TrackBitcoinAddress(Stub(MultistreamHolder)) TrackBitcoinAddress track = new TrackBitcoinAddress(Stub(MultistreamHolder))
def address = new TrackBitcoinAddress.Address(
Chain.BITCOIN, "1K7xkspJg7DDKNwzXgoRSDCUxiFsRegsSK"
)
when: when:
def total = track.getTotal(Chain.BITCOIN, ["1K7xkspJg7DDKNwzXgoRSDCUxiFsRegsSK", "35hK24tcLEWcgNA4JxpvbkNkoAcDGqQPsP"], unspents).sort { it.address.address } def total = track.totalUnspent(address, true, unspents)
then: then:
total.size() == 2 total.balance == 223
with(total[0]) { total.utxo.size() == 2
address.chain == Chain.BITCOIN total.utxo[0].txid == "f14b222e652c58d11435fa9172ddea000c6f5e20e6b715eb940fc28d1c4adeef"
address.address == "1K7xkspJg7DDKNwzXgoRSDCUxiFsRegsSK" total.utxo[1].txid == "17d1c4adf14b222e652c58d11435fa9ee2ddea000c6f5e20e6b715eb940fc28f"
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"
}
} }
def "One address for single provided"() { def "One address for single provided"() {
@@ -140,7 +116,7 @@ class TrackBitcoinAddressSpec extends Specification {
) )
.build() .build()
when: when:
def act = track.allAddresses(req) def act = track.allAddresses(Stub(BitcoinMultistream), req).collectList().block()
then: then:
act == ["16rCmCmbuWDhPjWTrpQGaU3EPdZF7MTdUk"] act == ["16rCmCmbuWDhPjWTrpQGaU3EPdZF7MTdUk"]
} }
@@ -161,20 +137,58 @@ class TrackBitcoinAddressSpec extends Specification {
) )
.build() .build()
when: when:
def act = track.allAddresses(req) def act = track.allAddresses(Stub(BitcoinMultistream), req).collectList().block()
then: then:
act == ["16rCmCmbuWDhPjWTrpQGaU3EPdZF7MTdUk", "1K7xkspJg7DDKNwzXgoRSDCUxiFsRegsSK", "3BMqADKWoWHPASsUdHvnUL6E1jpZkMnLZz", "bc1qdthqvt6cllzej7uhdddrltdfsmnt7d0gl5ue5n"] act == ["16rCmCmbuWDhPjWTrpQGaU3EPdZF7MTdUk", "1K7xkspJg7DDKNwzXgoRSDCUxiFsRegsSK", "3BMqADKWoWHPASsUdHvnUL6E1jpZkMnLZz", "bc1qdthqvt6cllzej7uhdddrltdfsmnt7d0gl5ue5n"]
} }
def "Null for no address provided"() { def "Empty for no address provided"() {
setup: setup:
TrackBitcoinAddress track = new TrackBitcoinAddress(Stub(MultistreamHolder)) TrackBitcoinAddress track = new TrackBitcoinAddress(Stub(MultistreamHolder))
def req = BlockchainOuterClass.BalanceRequest.newBuilder() def req = BlockchainOuterClass.BalanceRequest.newBuilder()
.build() .build()
when: when:
def act = track.allAddresses(req) def act = track.allAddresses(Stub(BitcoinMultistream), req).collectList().block()
then: 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"() { def "Build proto for common balance"() {
@@ -221,14 +235,21 @@ class TrackBitcoinAddressSpec extends Specification {
def blocks = TopicProcessor.create() def blocks = TopicProcessor.create()
Head head = Mock(Head) { 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 def upstream = null
upstream = Mock(BitcoinMultistream) { upstream = Mock(BitcoinMultistream) {
_ * getReader() >> Mock(BitcoinReader) { _ * getReader() >> Mock(BitcoinReader) {
2 * listUnspent() >>> [ 2 * listUnspent(_) >>> [
Mono.just([]), Mono.just([]),
Mono.just([[address: "1K7xkspJg7DDKNwzXgoRSDCUxiFsRegsSK", amount: 0.0123]]) Mono.just([
new SimpleUnspent("f14b222e652c58d11435fa9172ddea000c6f5e20e6b715eb940fc28d1c4adeef", 0, 1230000L, 123L)
])
] ]
} }
_ * getHead() >> head _ * 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 { class JsonRpcHttpClientSpec extends Specification {
ClientAndServer mockServer ClientAndServer mockServer
int port = 19332
def setup() { def setup() {
mockServer = ClientAndServer.startClientAndServer(18332); mockServer = ClientAndServer.startClientAndServer(port);
} }
def cleanup() { def cleanup() {
@@ -42,7 +43,7 @@ class JsonRpcHttpClientSpec extends Specification {
def "Make a request"() { def "Make a request"() {
setup: setup:
JsonRpcHttpClient client = new JsonRpcHttpClient("localhost:18332", null, null) JsonRpcHttpClient client = new JsonRpcHttpClient("localhost:${port}", null, null)
def resp = '{' + def resp = '{' +
' "jsonrpc": "2.0",' + ' "jsonrpc": "2.0",' +
' "result": "0x98de45",' + ' "result": "0x98de45",' +
@@ -64,7 +65,7 @@ class JsonRpcHttpClientSpec extends Specification {
def "Make request with basic auth"() { def "Make request with basic auth"() {
setup: setup:
def auth = new AuthConfig.ClientBasicAuth("user", "passwd") 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( mockServer.when(
HttpRequest.request() HttpRequest.request()
@@ -92,7 +93,7 @@ class JsonRpcHttpClientSpec extends Specification {
def "Produces RPC Exception on error status code"() { def "Produces RPC Exception on error status code"() {
setup: setup:
def client = new JsonRpcHttpClient("localhost:18332", null, null) def client = new JsonRpcHttpClient("localhost:${port}", null, null)
mockServer.when( mockServer.when(
HttpRequest.request() 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 version: v1
defaultOptions: defaults:
- chains: - chains:
- bitcoin - bitcoin
options: options: