solution: track Bitcoin transactions

This commit is contained in:
Igor Artamonov
2020-04-22 22:54:27 -04:00
parent 548154609d
commit a41230a00a
11 changed files with 546 additions and 20 deletions

View File

@@ -31,7 +31,7 @@ import reactor.core.publisher.Mono
class BlockchainRpc(
@Autowired private val nativeCall: NativeCall,
@Autowired private val streamHead: StreamHead,
@Autowired private val trackEthereumTx: TrackEthereumTx,
@Autowired private val trackTx: List<TrackTx>,
@Autowired private val trackAddress: List<TrackAddress>,
@Autowired private val describe: Describe,
@Autowired private val subscribeStatus: SubscribeStatus
@@ -48,7 +48,11 @@ class BlockchainRpc(
}
override fun subscribeTxStatus(request: Mono<BlockchainOuterClass.TxStatusRequest>): Flux<BlockchainOuterClass.TxStatus> {
return trackEthereumTx.add(request)
return request.flatMapMany { request ->
val chain = Chain.byId(request.chainValue)
trackTx.find { it.isSupported(chain) }?.subscribe(request)
?: Flux.error(SilentException.UnsupportedBlockchain(chain))
}
}
override fun subscribeBalance(requestMono: Mono<BlockchainOuterClass.BalanceRequest>): Flux<BlockchainOuterClass.AddressBalance> {

View File

@@ -0,0 +1,158 @@
package io.emeraldpay.dshackle.rpc
import com.google.protobuf.ByteString
import io.emeraldpay.api.proto.BlockchainOuterClass
import io.emeraldpay.api.proto.Common
import io.emeraldpay.dshackle.BlockchainType
import io.emeraldpay.dshackle.SilentException
import io.emeraldpay.dshackle.upstream.Selector
import io.emeraldpay.dshackle.upstream.Upstream
import io.emeraldpay.dshackle.upstream.Upstreams
import io.emeraldpay.dshackle.upstream.bitcoin.BitcoinApi
import io.emeraldpay.dshackle.upstream.bitcoin.ExtractBlock
import io.emeraldpay.grpc.Chain
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import java.math.BigInteger
import java.time.Duration
import java.time.Instant
import kotlin.math.max
import kotlin.math.min
@Service
class TrackBitcoinTx(
@Autowired private val upstreams: Upstreams
) : TrackTx {
companion object {
private val log = LoggerFactory.getLogger(TrackBitcoinTx::class.java)
}
override fun isSupported(chain: Chain): Boolean {
return BlockchainType.fromBlockchain(chain) == BlockchainType.BITCOIN && upstreams.isAvailable(chain)
}
override fun subscribe(request: BlockchainOuterClass.TxStatusRequest): Flux<BlockchainOuterClass.TxStatus> {
val chain = Chain.byId(request.chainValue)
val upstream = upstreams.getUpstream(chain)?.castApi(BitcoinApi::class.java)
?: return Flux.error(SilentException.UnsupportedBlockchain(chain))
val txid = request.txId
val confirmations = max(min(1, request.confirmationLimit), 12)
return upstream.getApi(Selector.empty).flatMapMany { api ->
subscribe(chain, api, upstream, txid)
}.takeUntil { tx ->
tx.confirmations >= confirmations
}.map(this::asProto)
}
fun subscribe(chain: Chain, api: BitcoinApi, upstream: Upstream<BitcoinApi>, txid: String): Flux<TxStatus> {
return loadExisting(api, txid)
.flatMapMany { status ->
if (status.mined) {
//Head almost always knows the current height, so it can continue with calculating confirmations
//without publishing an empty TxStatus first
continueWithMined(api, upstream, status)
} else {
loadMempool(api, txid)
.flatMapMany { tx ->
val next = if (tx.found) {
untilMined(upstream, tx)
} else {
untilFound(chain, api, upstream, txid)
}
//fist provide the current status, then updates
Flux.concat(Mono.just(tx), next)
}
}
}
}
fun continueWithMined(api: BitcoinApi, upstream: Upstream<BitcoinApi>, status: TxStatus): Flux<TxStatus> {
return api.getBlock(status.blockHash!!)
.map { block ->
TxStatus(status.txid, true, ExtractBlock.getHeight(block), true, status.blockHash, ExtractBlock.getTime(block), ExtractBlock.getDifficulty(block))
}.flatMapMany { tx ->
withConfirmations(upstream, tx)
}
}
fun untilFound(chain: Chain, api: BitcoinApi, upstream: Upstream<BitcoinApi>, txid: String): Flux<TxStatus> {
return Flux.interval(Duration.ofSeconds(1))
.take(Duration.ofMinutes(10))
.flatMap { loadMempool(api, txid) }
.skipUntil { it.found }
.flatMap { subscribe(chain, api, upstream, txid) }
.doOnError { t ->
log.error("Failed to wait until found", t)
}
}
fun untilMined(upstream: Upstream<BitcoinApi>, tx: TxStatus): Mono<TxStatus> {
return upstream.getHead().getFlux().flatMap {
upstream.getApi(Selector.empty).flatMap { api ->
loadExisting(api, tx.txid)
}.filter { it.mined }
}.single()
}
fun withConfirmations(upstream: Upstream<BitcoinApi>, tx: TxStatus): Flux<TxStatus> {
return upstream.getHead().getFlux().map {
tx.withHead(it.height)
}
}
fun loadExisting(api: BitcoinApi, txid: String): Mono<TxStatus> {
val mined = api.getTx(txid)
return mined.map {
val block = it["blockhash"] as String?
TxStatus(txid, found = true, mined = block != null, blockHash = block, height = ExtractBlock.getHeight(it))
}
}
fun loadMempool(api: BitcoinApi, txid: String): Mono<TxStatus> {
val mempool = api.getMempool()
return mempool.map {
if (it.contains(txid)) {
TxStatus(txid, found = true, mined = false)
} else {
TxStatus(txid, found = false, mined = false)
}
}
}
private fun asProto(tx: TxStatus): BlockchainOuterClass.TxStatus {
val data = BlockchainOuterClass.TxStatus.newBuilder()
.setTxId(tx.txid)
.setConfirmations(tx.confirmations.toInt())
data.broadcasted = tx.found
val isMined = tx.mined
data.mined = isMined
if (isMined) {
data.setBlock(
Common.BlockInfo.newBuilder()
.setBlockId(tx.blockHash!!.substring(2))
.setTimestamp(tx.blockTime!!.toEpochMilli())
.setWeight(ByteString.copyFrom(tx.blockTotalDifficulty!!.toByteArray()))
.setHeight(tx.height!!)
)
}
return data.build()
}
class TxStatus(
val txid: String,
val found: Boolean = false,
val height: Long? = null,
val mined: Boolean = false,
val blockHash: String? = null,
val blockTime: Instant? = null,
val blockTotalDifficulty: BigInteger? = null,
val confirmations: Long = 0) {
fun withHead(headHeight: Long) = TxStatus(txid, found, height, mined, blockHash, blockTime, blockTotalDifficulty, headHeight - height!! + 1)
}
}

View File

@@ -58,7 +58,7 @@ import kotlin.math.min
class TrackEthereumTx(
@Autowired private val upstreams: Upstreams,
@Autowired private val upstreamScheduler: Scheduler
) {
) : TrackTx {
companion object {
private val ZERO_BLOCK = BlockHash.from("0x0000000000000000000000000000000000000000000000000000000000000000")
@@ -75,6 +75,10 @@ class TrackEthereumTx(
val notFound = ConcurrentLinkedQueue<TrackedTx>()
override fun isSupported(chain: Chain): Boolean {
return BlockchainType.fromBlockchain(chain) == BlockchainType.ETHEREUM && upstreams.isAvailable(chain)
}
@PostConstruct
fun init() {
upstreams.observeChains().subscribe { chain ->
@@ -165,7 +169,7 @@ class TrackEthereumTx(
fun streamAllUpdates(tx: TxDetails): Flux<BlockchainOuterClass.TxStatus> {
val current = checkForUpdate(tx)
.doOnNext (this::onFirstUpdate)
.doOnNext(this::onFirstUpdate)
.map { Notification(it, asProto(it)) }
val updates = Flux.from(tx.bus)
@@ -175,12 +179,9 @@ class TrackEthereumTx(
.map { it.proto }
}
fun add(requestMono: Mono<BlockchainOuterClass.TxStatusRequest>): Flux<BlockchainOuterClass.TxStatus> {
return requestMono.map { request ->
prepareTracking(request)
}.flatMapMany { tx ->
streamAllUpdates(tx)
}
override fun subscribe(request: BlockchainOuterClass.TxStatusRequest): Flux<BlockchainOuterClass.TxStatus> {
val tx = prepareTracking(request)
return streamAllUpdates(tx)
}
private fun verifyAll(chain: Chain) {

View File

@@ -0,0 +1,10 @@
package io.emeraldpay.dshackle.rpc
import io.emeraldpay.api.proto.BlockchainOuterClass
import io.emeraldpay.grpc.Chain
import reactor.core.publisher.Flux
interface TrackTx {
fun isSupported(chain: Chain): Boolean
fun subscribe(request: BlockchainOuterClass.TxStatusRequest): Flux<BlockchainOuterClass.TxStatus>
}

View File

@@ -49,4 +49,5 @@ abstract class AbstractHead : Head {
fun getCurrent(): BlockContainer? {
return head.get()
}
}

View File

@@ -17,6 +17,7 @@ package io.emeraldpay.dshackle.upstream
import io.emeraldpay.dshackle.data.BlockContainer
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
interface Head {
fun getFlux(): Flux<BlockContainer>

View File

@@ -41,4 +41,16 @@ open class BitcoinApi(
}
}
}
open fun getBlock(hash: String): Mono<Map<String, Any>> {
return executeAndResult(0, "getblock", listOf(hash), Map::class.java) as Mono<Map<String, Any>>
}
open fun getTx(txid: String): Mono<Map<String, Any>> {
return executeAndResult(0, "getrawtransaction", listOf(txid, true), Map::class.java) as Mono<Map<String, Any>>
}
open fun getMempool(): Mono<List<String>> {
return executeAndResult(0, "getrawmempool", emptyList(), List::class.java) as Mono<List<String>>
}
}

View File

@@ -15,26 +15,42 @@ class ExtractBlock(
companion object {
private val log = LoggerFactory.getLogger(ExtractBlock::class.java)
@JvmStatic
fun getHeight(data: Map<String, Any>): Long? {
val height = data["height"] as Number? ?: return null
return height.toLong()
}
@JvmStatic
fun getTime(data: Map<String, Any>): Instant? {
val time = data["time"] as Number? ?: return null
return Instant.ofEpochMilli(time.toLong() * 1000)
}
@JvmStatic
fun getDifficulty(data: Map<String, Any>): BigInteger? {
val chainwork = data["chainwork"] as String? ?: return null
return BigInteger(1, Hex.decodeHex(chainwork))
}
}
fun extract(json: ByteArray): BlockContainer {
val data = objectMapper.readValue(json, Map::class.java) as Map<String, Any>
val height = data["height"] as Number? ?: throw IllegalArgumentException("Block JSON has no height")
val time = data["time"] as Number? ?: throw IllegalArgumentException("Block JSON has no time")
val hash = data["hash"] as String? ?: throw IllegalArgumentException("Block JSON has no hash")
val chainwork = data["chainwork"] as String? ?: throw IllegalArgumentException("Block JSON has no chainwork")
val transactions = (data["tx"] as List<String>?)?.map(TxId.Companion::from) ?: emptyList()
return BlockContainer(
height.toLong(),
getHeight(data) ?: throw IllegalArgumentException("Block JSON has no height"),
BlockId.from(hash),
BigInteger(1, Hex.decodeHex(chainwork)),
Instant.ofEpochMilli(time.toLong() * 1000),
getDifficulty(data) ?: throw IllegalArgumentException("Block JSON has no chainwork"),
getTime(data) ?: throw IllegalArgumentException("Block JSON has no time"),
false,
json,
transactions
)
}
}