This commit is contained in:
terminal
2022-08-01 13:40:35 +04:00
19 changed files with 158 additions and 51 deletions

View File

@@ -247,7 +247,11 @@ class UpstreamsConfigReader(
upNode: MappingNode,
upstream: UpstreamsConfig.Upstream<UpstreamsConfig.GrpcConnection>
) {
// Dshackle gRPC connection dispatches requests to different upstreams, which may
// be on different blockchains, and each may have different set of labels.
// So the labels and chains assigned to the gRPC connection make no sense.
if (hasAny(upNode, "labels")) {
// Actual labels from underlying upstreams are handled by GrpcUpstreamStatus
log.warn("Labels should be not applied to gRPC upstream")
}
if (hasAny(upNode, "chain")) {

View File

@@ -57,7 +57,7 @@ class TrackBitcoinAddress(
/**
* Keep tracking of the current state of local upstreams. True for a chain that has an upstream with balance data.
*/
private val balanceAvailable: MutableMap<Chain, Boolean> = ConcurrentHashMap()
private val localBalanceAvailable: MutableMap<Chain, Boolean> = ConcurrentHashMap()
/**
* Criteria for a remote grpc upstream that can provide a balance
@@ -72,7 +72,7 @@ class TrackBitcoinAddress(
multistreamHolder.observeChains().subscribe { chain ->
multistreamHolder.getUpstream(chain)?.let { mup ->
val available = mup.getAll().any { up ->
!up.isGrpc() && (up.getOptions().providesBalance ?: false)
!up.isGrpc() && up.getCapabilities().contains(Capability.BALANCE)
}
setBalanceAvailability(chain, available)
}
@@ -80,14 +80,14 @@ class TrackBitcoinAddress(
}
fun setBalanceAvailability(chain: Chain, enabled: Boolean) {
balanceAvailable[chain] = enabled
localBalanceAvailable[chain] = enabled
}
/**
* @return true if the current instance has data sources to provide the balance
*/
fun isBalanceAvailable(chain: Chain): Boolean {
return balanceAvailable[chain] ?: false
return localBalanceAvailable[chain] ?: false
}
fun allAddresses(api: BitcoinMultistream, request: BlockchainOuterClass.BalanceRequest): Flux<String> {
@@ -179,10 +179,9 @@ class TrackBitcoinAddress(
}
.timeout(Defaults.timeoutInternal, Mono.empty())
.switchIfEmpty(
Mono.just(0)
.doOnNext {
log.warn("No upstream providing balance for ${api.chain}")
}
Mono.fromCallable {
log.warn("No upstream providing balance for ${api.chain}")
}
.then(Mono.error(SilentException.DataUnavailable("BALANCE")))
)
}

View File

@@ -263,7 +263,7 @@ open class ConfiguredUpstreams(
log.info("Using ALL CHAINS (gRPC) upstream, at ${endpoint.host}:${endpoint.port}")
ds.start()
.doOnNext {
log.info("Chain ${it.chain} has ${it.type} through gRPC at ${endpoint.host}:${endpoint.port}")
log.info("Chain ${it.chain} ${it.type} through gRPC at ${endpoint.host}:${endpoint.port}. With caps: ${it.upstream.getCapabilities()}")
}
.subscribe(currentUpstreams::update)
}

View File

@@ -26,8 +26,9 @@ import io.emeraldpay.dshackle.upstream.Multistream
import io.emeraldpay.dshackle.upstream.RequestPostprocessor
import io.emeraldpay.dshackle.upstream.Selector
import io.emeraldpay.dshackle.upstream.Upstream
import io.emeraldpay.dshackle.upstream.ethereum.LocalCallRouter
import io.emeraldpay.dshackle.upstream.bitcoin.LocalCallRouter
import io.emeraldpay.dshackle.upstream.forkchoice.MostWorkForkChoice
import io.emeraldpay.dshackle.upstream.calls.DefaultBitcoinMethods
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcRequest
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcResponse
import io.emeraldpay.grpc.Chain
@@ -38,28 +39,34 @@ import reactor.core.publisher.Mono
@Suppress("UNCHECKED_CAST")
open class BitcoinMultistream(
chain: Chain,
val upstreams: MutableList<BitcoinUpstream>,
private val sourceUpstreams: MutableList<BitcoinUpstream>,
caches: Caches
) : Multistream(chain, upstreams as MutableList<Upstream>, caches, RequestPostprocessor.Empty()), Lifecycle {
) : Multistream(chain, sourceUpstreams as MutableList<Upstream>, caches, RequestPostprocessor.Empty()), Lifecycle {
companion object {
private val log = LoggerFactory.getLogger(BitcoinMultistream::class.java)
}
private var head: Head = EmptyHead()
private var esplora = upstreams.find { it.esploraClient != null }?.esploraClient
private var esplora = sourceUpstreams.find { it.esploraClient != null }?.esploraClient
private var reader = BitcoinReader(this, head, esplora)
private var addressActiveCheck: AddressActiveCheck? = null
private var xpubAddresses: XpubAddresses? = null
private val feeEstimation = BitcoinFees(this, reader, 6)
private var callRouter: LocalCallRouter = LocalCallRouter(DefaultBitcoinMethods(), reader)
override fun init() {
if (upstreams.size > 0) {
if (sourceUpstreams.size > 0) {
head = updateHead()
}
super.init()
}
open val upstreams: List<BitcoinUpstream>
get() {
return sourceUpstreams
}
override fun getFeeEstimation(): ChainFees {
return feeEstimation
}
@@ -76,8 +83,8 @@ open class BitcoinMultistream(
}
lagObserver?.stop()
lagObserver = null
val head = if (upstreams.size == 1) {
val upstream = upstreams.first()
val head = if (sourceUpstreams.size == 1) {
val upstream = sourceUpstreams.first()
upstream.setLag(0)
upstream.getHead().apply {
if (this is Lifecycle) {
@@ -85,10 +92,10 @@ open class BitcoinMultistream(
}
}
} else {
val newHead = MergedHead(upstreams.map { it.getHead() }, MostWorkForkChoice()).apply {
this.start()
}
val lagObserver = BitcoinHeadLagObserver(newHead, upstreams)
val newHead = MergedHead(sourceUpstreams.map { it.getHead() }, MostWorkForkChoice()).apply {
this.start()
}
val lagObserver = BitcoinHeadLagObserver(newHead, sourceUpstreams)
this.lagObserver = lagObserver
lagObserver.start()
newHead
@@ -98,7 +105,7 @@ open class BitcoinMultistream(
}
override fun getRoutedApi(matcher: Selector.Matcher): Mono<Reader<JsonRpcRequest, JsonRpcResponse>> {
return Mono.just(LocalCallRouter(getMethods()))
return Mono.just(callRouter)
}
open fun getReader(): BitcoinReader {
@@ -107,10 +114,11 @@ open class BitcoinMultistream(
override fun onUpstreamsUpdated() {
super.onUpstreamsUpdated()
esplora = upstreams.find { it.esploraClient != null }?.esploraClient
esplora = sourceUpstreams.find { it.esploraClient != null }?.esploraClient
reader = BitcoinReader(this, this.head, esplora)
addressActiveCheck = esplora?.let { AddressActiveCheck(it) }
xpubAddresses = addressActiveCheck?.let { XpubAddresses(it) }
callRouter = LocalCallRouter(getMethods(), reader)
}
override fun setHead(head: Head) {
@@ -123,7 +131,7 @@ open class BitcoinMultistream(
}
override fun getLabels(): Collection<UpstreamsConfig.Labels> {
return upstreams.flatMap { it.getLabels() }
return sourceUpstreams.flatMap { it.getLabels() }
}
@Suppress("UNCHECKED_CAST")

View File

@@ -17,6 +17,7 @@ package io.emeraldpay.dshackle.upstream.bitcoin
import com.fasterxml.jackson.databind.ObjectMapper
import io.emeraldpay.dshackle.Global
import io.emeraldpay.dshackle.upstream.Capability
import io.emeraldpay.dshackle.upstream.Head
import io.emeraldpay.dshackle.upstream.Selector
import io.emeraldpay.dshackle.upstream.bitcoin.data.SimpleUnspent
@@ -43,6 +44,8 @@ open class BitcoinReader(
private val unspentReader: UnspentReader = if (esploraClient != null) {
EsploraUnspentReader(esploraClient, head)
} else if (upstreams.upstreams.any { it.isGrpc() && it.getCapabilities().contains(Capability.BALANCE) }) {
RemoteUnspentReader(upstreams)
} else {
RpcUnspentReader(upstreams)
}

View File

@@ -37,7 +37,6 @@ class EsploraUnspentReader(
base.txid,
base.vout,
base.value,
head.getCurrentHeight()?.let { base.height - it } ?: 0
)
}

View File

@@ -15,12 +15,17 @@
*/
package io.emeraldpay.dshackle.upstream.bitcoin
import io.emeraldpay.dshackle.Global
import io.emeraldpay.dshackle.SilentException
import io.emeraldpay.dshackle.reader.Reader
import io.emeraldpay.dshackle.upstream.bitcoin.data.RpcUnspent
import io.emeraldpay.dshackle.upstream.bitcoin.data.SimpleUnspent
import io.emeraldpay.dshackle.upstream.calls.CallMethods
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcRequest
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcResponse
import io.emeraldpay.etherjar.rpc.RpcException
import io.emeraldpay.etherjar.rpc.RpcResponseError
import org.bitcoinj.core.Address
import org.slf4j.LoggerFactory
import reactor.core.publisher.Mono
@@ -33,6 +38,7 @@ import reactor.core.publisher.Mono
*/
class LocalCallRouter(
private val methods: CallMethods,
private val reader: BitcoinReader,
) : Reader<JsonRpcRequest, JsonRpcResponse> {
companion object {
@@ -47,6 +53,39 @@ class LocalCallRouter(
if (!methods.isCallable(key.method)) {
return Mono.error(RpcException(RpcResponseError.CODE_METHOD_NOT_EXIST, "Unsupported method"))
}
if (key.method == "listunspent") {
return processUnspentRequest(key)
}
return Mono.empty()
}
/**
*
*/
fun processUnspentRequest(key: JsonRpcRequest): Mono<JsonRpcResponse> {
if (key.params.size < 3) {
return Mono.error(SilentException("Invalid call to unspent. Address is missing"))
}
val addresses = key.params[2]
if (addresses is List<*> && addresses.size > 0) {
val address = addresses[0].toString().let { Address.fromString(null, it) }
return reader.listUnspent(address).map {
val rpc = it.map(convertUnspent(address))
val json = Global.objectMapper.writeValueAsBytes(rpc)
JsonRpcResponse.ok(json, JsonRpcResponse.NumberId(key.id))
}
}
return Mono.error(SilentException("Invalid call to unspent"))
}
fun convertUnspent(address: Address): (SimpleUnspent) -> RpcUnspent {
return { base ->
RpcUnspent(
base.txid,
base.vout,
address.toString(),
base.value,
)
}
}
}

View File

@@ -0,0 +1,48 @@
package io.emeraldpay.dshackle.upstream.bitcoin
import io.emeraldpay.api.proto.BlockchainOuterClass.BalanceRequest
import io.emeraldpay.dshackle.upstream.Capability
import io.emeraldpay.dshackle.upstream.Selector
import io.emeraldpay.dshackle.upstream.bitcoin.data.SimpleUnspent
import io.emeraldpay.dshackle.upstream.grpc.BitcoinGrpcUpstream
import org.bitcoinj.core.Address
import org.slf4j.LoggerFactory
import reactor.core.publisher.Mono
class RemoteUnspentReader(
val upstreams: BitcoinMultistream
) : UnspentReader {
companion object {
private val log = LoggerFactory.getLogger(RemoteUnspentReader::class.java)
}
private val selector = Selector.LocalAndMatcher(
Selector.GrpcMatcher(),
Selector.CapabilityMatcher(Capability.BALANCE)
)
override fun read(key: Address): Mono<List<SimpleUnspent>> {
val apis = upstreams.getApiSource(selector)
apis.request(1)
return Mono.from(apis)
.map { up ->
up.cast(BitcoinGrpcUpstream::class.java).remote
}
.flatMapMany {
val request = BalanceRequest.newBuilder()
.build()
it.getBalance(request)
}
.map { resp ->
resp.utxoList.map { utxo ->
SimpleUnspent(
utxo.txId,
utxo.index.toInt(),
utxo.balance.toLong(),
)
}
}
.reduce(List<SimpleUnspent>::plus)
}
}

View File

@@ -16,6 +16,8 @@
package io.emeraldpay.dshackle.upstream.bitcoin
import io.emeraldpay.dshackle.Global
import io.emeraldpay.dshackle.SilentException
import io.emeraldpay.dshackle.upstream.Capability
import io.emeraldpay.dshackle.upstream.Selector
import io.emeraldpay.dshackle.upstream.bitcoin.data.RpcUnspent
import io.emeraldpay.dshackle.upstream.bitcoin.data.SimpleUnspent
@@ -38,14 +40,17 @@ class RpcUnspentReader(
base.txid,
base.vout,
base.amount,
base.confirmations
)
}
private val selector = Selector.CapabilityMatcher(Capability.BALANCE)
override fun read(key: Address): Mono<List<SimpleUnspent>> {
// docs: https://developer.bitcoin.org/reference/rpc/listunspent.html
//
val address = key.toString()
return upstreams.getDirectApi(Selector.empty).flatMap { api ->
api.read(JsonRpcRequest("listunspent", emptyList()))
return upstreams.getDirectApi(selector).flatMap { api ->
api.read(JsonRpcRequest("listunspent", listOf(1, 9999999, listOf(address))))
.flatMap(JsonRpcResponse::requireResult)
.map {
Global.objectMapper.readerFor(RpcUnspent::class.java).readValues<RpcUnspent>(it).readAll()
@@ -55,6 +60,6 @@ class RpcUnspentReader(
it.address == address
}.map(convert)
}
}
}.switchIfEmpty(Mono.error(SilentException.DataUnavailable("BALANCE")))
}
}

View File

@@ -20,5 +20,4 @@ data class RpcUnspent(
val vout: Int,
val address: String,
val amount: Long,
val confirmations: Long
)

View File

@@ -30,7 +30,6 @@ class RpcUnspentDeserializer : JsonDeserializer<RpcUnspent>() {
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

@@ -19,5 +19,4 @@ data class SimpleUnspent(
val txid: String,
val vout: Int,
val value: Long,
val confirmations: Long
)

View File

@@ -100,6 +100,10 @@ class BitcoinGrpcUpstream(
var timeout = Defaults.timeout
private var capabilities: Set<Capability> = emptySet()
override fun getBlockchainApi(): ReactorBlockchainGrpc.ReactorBlockchainStub {
return remote
}
override fun getHead(): Head {
return grpcHead
}

View File

@@ -100,6 +100,10 @@ open class EthereumGrpcUpstream(
private val defaultReader: Reader<JsonRpcRequest, JsonRpcResponse> = client.forSelector(Selector.empty)
var timeout = Defaults.timeout
override fun getBlockchainApi(): ReactorBlockchainGrpc.ReactorBlockchainStub {
return remote
}
override fun start() {
}

View File

@@ -16,12 +16,16 @@
package io.emeraldpay.dshackle.upstream.grpc
import io.emeraldpay.api.proto.BlockchainOuterClass
import io.emeraldpay.api.proto.ReactorBlockchainGrpc
import io.emeraldpay.dshackle.upstream.Upstream
interface GrpcUpstream {
interface GrpcUpstream : Upstream {
/**
* Update the configuration of the upstream with the new data.
* Called on the first creation, and each time a new state received from upstream
*/
fun update(conf: BlockchainOuterClass.DescribeChain)
fun getBlockchainApi(): ReactorBlockchainGrpc.ReactorBlockchainStub
}

View File

@@ -92,7 +92,7 @@ class GrpcUpstreams(
client.describe(BlockchainOuterClass.DescribeRequest.newBuilder().build())
}.onErrorContinue { t, _ ->
if (ExceptionUtils.indexOfType(t, ConnectException::class.java) >= 0) {
log.warn("gRPC upstream $host:$port is unavailable")
log.warn("gRPC upstream $host:$port is unavailable. (${t.javaClass}: ${t.message})")
known.values.forEach {
it.setStatus(UpstreamAvailability.UNAVAILABLE)
}

View File

@@ -51,7 +51,7 @@ class TrackBitcoinAddressSpec extends Specification {
def "Correct sum for single"() {
setup:
def unspents = [
new SimpleUnspent("f14b222e652c58d11435fa9172ddea000c6f5e20e6b715eb940fc28d1c4adeef", 0, 100L, 123L)
new SimpleUnspent("f14b222e652c58d11435fa9172ddea000c6f5e20e6b715eb940fc28d1c4adeef", 0, 100L)
]
TrackBitcoinAddress track = new TrackBitcoinAddress(Stub(MultistreamHolder))
def address = new TrackBitcoinAddress.Address(
@@ -68,8 +68,8 @@ class TrackBitcoinAddressSpec extends Specification {
def "Correct sum for few"() {
setup:
def unspents = [
new SimpleUnspent("f14b222e652c58d11435fa9172ddea000c6f5e20e6b715eb940fc28d1c4adeef", 0, 100L, 123L),
new SimpleUnspent("17d1c4adf14b222e652c58d11435fa9ee2ddea000c6f5e20e6b715eb940fc28f", 0, 123L, 123L),
new SimpleUnspent("f14b222e652c58d11435fa9172ddea000c6f5e20e6b715eb940fc28d1c4adeef", 0, 100L),
new SimpleUnspent("17d1c4adf14b222e652c58d11435fa9ee2ddea000c6f5e20e6b715eb940fc28f", 0, 123L),
]
TrackBitcoinAddress track = new TrackBitcoinAddress(Stub(MultistreamHolder))
def address = new TrackBitcoinAddress.Address(
@@ -120,8 +120,8 @@ class TrackBitcoinAddressSpec extends Specification {
def "Correct sum for few with utxo"() {
setup:
def unspents = [
new SimpleUnspent("f14b222e652c58d11435fa9172ddea000c6f5e20e6b715eb940fc28d1c4adeef", 0, 100L, 123L),
new SimpleUnspent("17d1c4adf14b222e652c58d11435fa9ee2ddea000c6f5e20e6b715eb940fc28f", 0, 123L, 123L),
new SimpleUnspent("f14b222e652c58d11435fa9172ddea000c6f5e20e6b715eb940fc28d1c4adeef", 0, 100L),
new SimpleUnspent("17d1c4adf14b222e652c58d11435fa9ee2ddea000c6f5e20e6b715eb940fc28f", 0, 123L),
]
TrackBitcoinAddress track = new TrackBitcoinAddress(Stub(MultistreamHolder))
def address = new TrackBitcoinAddress.Address(
@@ -282,7 +282,7 @@ class TrackBitcoinAddressSpec extends Specification {
2 * listUnspent(_) >>> [
Mono.just([]),
Mono.just([
new SimpleUnspent("f14b222e652c58d11435fa9172ddea000c6f5e20e6b715eb940fc28d1c4adeef", 0, 1230000L, 123L)
new SimpleUnspent("f14b222e652c58d11435fa9172ddea000c6f5e20e6b715eb940fc28d1c4adeef", 0, 1230000L)
])
]
}

View File

@@ -34,7 +34,8 @@ class BitcoinReaderSpec extends Specification {
api.answerOnce("getblockhash", [100000], "000000000003ba27aa200b1cecaad478d2b00432346c3f1f3986da1afd33e506")
api.answerOnce("getblock", ["000000000003ba27aa200b1cecaad478d2b00432346c3f1f3986da1afd33e506"], block)
def ups = Mock(BitcoinMultistream) {
_ * it.getDirectApi(_) >> Mono.just(api)
_ * getDirectApi(_) >> Mono.just(api)
_ * upstreams >> []
}
def reader = new BitcoinReader(ups, Stub(Head), null)

View File

@@ -29,7 +29,7 @@ class RpcUnspentReaderSpec extends Specification {
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))
1 * read(new JsonRpcRequest("listunspent", [1, 9999999, ["1K7xkspJg7DDKNwzXgoRSDCUxiFsRegsSK"]])) >> Mono.just(JsonRpcResponse.ok(json))
}
def upstreams = Mock(BitcoinMultistream) {
1 * getDirectApi(_) >> Mono.just(rpcReader)
@@ -45,19 +45,16 @@ class RpcUnspentReaderSpec extends Specification {
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
}
}
@@ -66,7 +63,7 @@ class RpcUnspentReaderSpec extends Specification {
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))
1 * read(new JsonRpcRequest("listunspent", [1, 9999999, ["35hK24tcLEWcgNA4JxpvbkNkoAcDGqQPsP"]])) >> Mono.just(JsonRpcResponse.ok(json))
}
def upstreams = Mock(BitcoinMultistream) {
1 * getDirectApi(_) >> Mono.just(rpcReader)
@@ -83,19 +80,16 @@ class RpcUnspentReaderSpec extends Specification {
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
}
}
@@ -104,7 +98,7 @@ class RpcUnspentReaderSpec extends Specification {
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))
1 * read(_) >> Mono.just(JsonRpcResponse.ok(json))
}
def upstreams = Mock(BitcoinMultistream) {
1 * getDirectApi(_) >> Mono.just(rpcReader)
@@ -122,13 +116,11 @@ class RpcUnspentReaderSpec extends Specification {
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
}
}